feat(cli): read shared artifacts

This commit is contained in:
Jinwoo-H
2026-08-31 19:28:19 -04:00
parent a5be10815c
commit 9a32deb633
16 changed files with 567 additions and 46 deletions
+11
View File
@@ -250,6 +250,7 @@ publishing public artifact links", and then re-run the command. If they do not w
it, deliver the file locally instead.
```text
ORCA artifacts read <id-or-share-url> [--output <path>] [--json]
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json
ORCA artifacts unshare <file> --json
@@ -257,6 +258,16 @@ ORCA artifacts list [--cursor <cursor>] --json
ORCA artifacts delete <id> --json
```
`artifacts read` retrieves the bounded rendered artifact bytes for a public share URL or
artifact id. The current service renders Markdown as HTML, so the output is raw HTML for
both source types; original Markdown is not available from this API. It does not open a
browser or execute scripts. Human-readable output writes only the content to stdout (use
`--output <path>` to save it); `--json` returns stable artifact metadata together with the
content for pipelines. Public links can be read
without signing in. If metadata is access-controlled, the active Orca account is used for
that metadata request; the content URL itself must remain a public artifact share. Private,
expired, and deleted artifacts fail without opening a browser.
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
- `share` saves the returned edit token in the active Orca profile and never includes it
in CLI output. `update` and `unshare` look up that record by the resolved local file
+23 -1
View File
@@ -1,4 +1,4 @@
import type { ArtifactListItem, ArtifactListPage } from '../shared/artifacts'
import type { ArtifactListItem, ArtifactListPage, ArtifactReadResult } from '../shared/artifacts'
export function formatArtifactList(artifacts: readonly ArtifactListItem[]): string {
if (artifacts.length === 0) {
@@ -20,3 +20,25 @@ export function formatArtifactListPage(page: ArtifactListPage): string {
export function formatArtifactShared(item: ArtifactListItem): string {
return item.shareUrl
}
export function formatArtifactRead(result: ArtifactReadResult): string {
return result.content
}
export function sanitizeArtifactTerminalContent(content: string): string {
const escape = String.fromCharCode(27)
const osc = new RegExp(
`${escape}\\][^${String.fromCharCode(7)}]*(?:${String.fromCharCode(7)}|${escape}\\\\)`,
'g'
)
const csi = new RegExp(`${escape}(?:\\[[0-9;?]*[ -/]*[@-~])`, 'g')
return content
.replace(osc, '')
.replace(csi, '')
.split('')
.filter((char) => {
const code = char.charCodeAt(0)
return code >= 32 || code === 9 || code === 10 || code === 13
})
.join('')
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -25,6 +25,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
{
name: 'artifacts',
keys: [
'artifacts read',
'artifacts list',
'artifacts share',
'artifacts update',
+33
View File
@@ -33,6 +33,39 @@ const item: ArtifactListItem = {
afterEach(() => vi.restoreAllMocks())
describe('artifact CLI handlers', () => {
it('reads an artifact URL and supports explicit output without executing HTML', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
const output = join(cwd, 'out.html')
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: {
status: 'ok',
value: {
artifact: item.artifact,
shareUrl: item.shareUrl,
contentType: 'text/html',
content: '<script>globalThis.pwned=true</script><h1>Safe bytes</h1>'
}
},
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await ARTIFACT_HANDLERS['artifacts read']!({
client: { call } as never,
cwd,
flags: new Map([
['id', item.shareUrl],
['output', 'out.html']
]),
json: false
})
expect(call).toHaveBeenCalledWith('artifacts.read', { input: item.shareUrl })
expect(
await import('node:fs/promises').then(({ readFile }) => readFile(output, 'utf8'))
).toContain('<script>')
expect(log).toHaveBeenCalledWith(`Artifact written to ${output}`)
})
it('reads a relative HTML file and sends sanitized content to the runtime', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
+30 -1
View File
@@ -1,9 +1,11 @@
import { basename, extname, resolve } from 'node:path'
import { writeFile } from 'node:fs/promises'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactListPage,
ArtifactListItem,
ArtifactReadResult,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
@@ -19,7 +21,12 @@ import {
} from '../../shared/artifact-sharing-gate'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
import {
formatArtifactListPage,
formatArtifactRead,
formatArtifactShared,
sanitizeArtifactTerminalContent
} from '../artifact-format'
import { printResult } from '../format'
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
@@ -164,6 +171,28 @@ function requireOperation<T>(operation: ArtifactCloudOperation<T>): T {
}
export const ARTIFACT_HANDLERS: Record<string, CommandHandler> = {
'artifacts read': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactReadResult>>(
'artifacts.read',
{ input: requireStringFlag(ctx, 'id'), ...cloudOptions(ctx) }
)
const value = requireOperation(response.result)
const output = stringFlag(ctx, 'output')
if (output) {
await writeFile(resolve(ctx.cwd, output), value.content, 'utf8')
}
if (ctx.json) {
printResult({ ...response, result: value }, true, formatArtifactRead)
} else if (output) {
console.log(`Artifact written to ${resolve(ctx.cwd, output)}`)
} else {
const content = formatArtifactRead(value)
process.stdout.write(
process.stdout.isTTY ? sanitizeArtifactTerminalContent(content) : content
)
}
},
'artifacts list': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const cursor = stringFlag(ctx, 'cursor')
+3
View File
@@ -102,6 +102,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
}
if (command === 'artifacts read' && flag === 'output') {
return '--output <path> Write artifact content to a file; human output suppresses stdout'
}
if (command === 'orchestration worker-read' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
}
+11
View File
@@ -4,6 +4,17 @@ import { GLOBAL_FLAGS } from '../args'
const CLOUD_FLAGS = ['api-url']
export const ARTIFACT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['artifacts', 'read'],
summary: 'Read an HTML or Markdown artifact by id or share URL',
usage: 'orca artifacts read <id-or-share-url> [--api-url <url>] [--output <path>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'output'],
positionalArgs: ['id'],
examples: [
'orca artifacts read https://share.onorca.dev/a/abc123',
'orca artifacts read abc123 --json'
]
},
{
path: ['artifacts', 'share'],
summary: 'Share an HTML or Markdown file with your Orca account',
+47 -5
View File
@@ -1,6 +1,28 @@
import type { ArtifactWriteRequest } from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
export type ArtifactFetchOptions = {
token?: string
method?: string
headers?: Record<string, string>
signal?: AbortSignal
body?: string
}
/** Shared first-party fetch policy for artifact metadata and public content. */
export function artifactFetch(url: string, options: ArtifactFetchOptions = {}): Promise<Response> {
return fetch(url, {
method: options.method ?? 'GET',
headers: {
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
...options.headers
},
body: options.body,
redirect: 'error',
signal: options.signal ?? AbortSignal.timeout(20_000)
})
}
export type ArtifactWriteBody = {
content: string
contentType: ArtifactWriteRequest['contentType']
@@ -23,17 +45,15 @@ export async function artifactRequest<T>(
path: string,
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
): Promise<T> {
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
const response = await artifactFetch(`${apiUrl}/v1/artifacts${path}`, {
token,
method: options.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
...(options.body ? { 'content-type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: 'error',
signal: AbortSignal.timeout(20_000)
body: options.body ? JSON.stringify(options.body) : undefined
})
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { code?: string } | null
@@ -44,3 +64,25 @@ export async function artifactRequest<T>(
}
return (await response.json()) as T
}
export async function deleteArtifactRequest(
apiUrl: string,
token: string,
path: string,
editToken?: string
): Promise<void> {
try {
await artifactRequest<void>(apiUrl, token, path, {
method: 'DELETE',
...(editToken ? { editToken } : {})
})
} catch (error) {
if (
!(error instanceof OrcaCloudRequestError) ||
error.statusCode !== 404 ||
error.errorCode !== 'artifact_not_found'
) {
throw error
}
}
}
+20 -38
View File
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactReadRequest,
ArtifactReadResult,
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
@@ -27,9 +29,9 @@ import {
removeArtifactShareRecords
} from './artifact-share-record-store'
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
import { artifactRequest, artifactWriteBody, deleteArtifactRequest } from './artifact-cloud-request'
import { ArtifactPublisher } from './artifact-publisher'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
import { ArtifactReadMetadataError, readArtifactContent } from './artifact-read'
type ArtifactAuthContext = {
profileId: string
@@ -37,28 +39,6 @@ type ArtifactAuthContext = {
assertCurrent: () => void
}
async function deleteArtifactRequest(
apiUrl: string,
token: string,
path: string,
editToken?: string
): Promise<void> {
try {
await artifactRequest<void>(apiUrl, token, path, {
method: 'DELETE',
...(editToken ? { editToken } : {})
})
} catch (error) {
if (
!(error instanceof OrcaCloudRequestError) ||
error.statusCode !== 404 ||
error.errorCode !== 'artifact_not_found'
) {
throw error
}
}
}
function tokenFingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
@@ -140,11 +120,6 @@ function explicitTokenAuthContext(
export class ArtifactCloudService {
private readonly publisher: ArtifactPublisher
/**
* `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
@@ -158,7 +133,22 @@ export class ArtifactCloudService {
return artifactRequest<ArtifactListPage>(apiUrl, token, query)
})
}
async read(request: ArtifactReadRequest): Promise<ArtifactCloudOperation<ArtifactReadResult>> {
const apiUrl = resolveArtifactCloudApiUrl(request.apiUrl)
try {
return {
status: 'ok',
value: await readArtifactContent(request.input, { apiUrl })
}
} catch (error) {
if (!(error instanceof ArtifactReadMetadataError) || ![401, 403].includes(error.statusCode)) {
throw error
}
}
return this.withAuth(request, (token, authenticatedApiUrl) =>
readArtifactContent(request.input, { apiUrl: authenticatedApiUrl, token })
)
}
getPublishedLink(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
@@ -172,9 +162,6 @@ export class ArtifactCloudService {
return record ? { shareUrl: record.shareUrl } : null
})
}
// 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()
@@ -182,7 +169,6 @@ export class ArtifactCloudService {
this.publisher.share(request, token, apiUrl, auth, idempotencyKey)
)
}
async publish(
request: ArtifactWriteRequest
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
@@ -192,7 +178,6 @@ export class ArtifactCloudService {
this.publisher.publish(request, token, apiUrl, auth, idempotencyKey)
)
}
async update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
assertArtifactSharingAllowed(this.isSharingEnabled)
return this.withAuth(request, (token, apiUrl, auth) =>
@@ -233,7 +218,6 @@ export class ArtifactCloudService {
})
)
}
unshare(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<void>> {
@@ -261,7 +245,6 @@ export class ArtifactCloudService {
})
)
}
delete(id: string, options: ArtifactCloudOptions): Promise<ArtifactCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl, auth) =>
this.publisher.runForSlug(id, auth, async () => {
@@ -272,7 +255,6 @@ export class ArtifactCloudService {
})
)
}
private async withAuth<T>(
options: ArtifactCloudOptions,
operation: (token: string, apiUrl: string, auth: ArtifactAuthContext) => Promise<T>
+105
View File
@@ -0,0 +1,105 @@
import { createServer, type Server } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { ARTIFACT_CLI_MAX_READ_BYTES } from '../../shared/artifacts'
import { readArtifactContent } from './artifact-read'
const metadata = {
artifact: {
version: 1,
slug: 'abc123',
title: null,
originalFileName: 'notes.md',
sourceContentType: 'text/markdown',
renderedContentType: 'text/html',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-12-01T00:00:00.000Z',
byteSize: 18,
deletedAt: null
},
shareUrl: 'http://127.0.0.1/a/abc123'
}
let server: Server | undefined
afterEach(async () => {
await new Promise<void>((resolve) => server?.close(() => resolve()) ?? resolve())
server = undefined
})
async function serve(
routes: Record<string, { body: string; type: string; status?: number }>
): Promise<string> {
server = createServer((request, response) => {
const route = routes[request.url ?? '']
if (!route) {
response.writeHead(404)
response.end()
return
}
response.writeHead(route.status ?? 200, { 'content-type': route.type })
response.end(route.body)
})
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('server did not bind')
}
return `http://127.0.0.1:${address.port}`
}
describe('readArtifactContent', () => {
it('reads rendered HTML from a public share wrapper and keeps scripts inert', async () => {
const api = await serve({
'/v1/artifacts/abc123': { body: JSON.stringify(metadata), type: 'application/json' },
'/a/abc123': { body: '<iframe src="/usercontent/abc123/artifact.html">', type: 'text/html' },
'/usercontent/abc123/artifact.html': {
body: '<script>alert(1)</script><h1>Hello</h1>',
type: 'text/html'
}
})
const result = await readArtifactContent('abc123', { apiUrl: api })
expect(result.content).toContain('<script>alert(1)</script>')
expect(result.contentType).toBe('text/html')
})
it('rejects malformed wrappers and oversized content', async () => {
const api = await serve({
'/v1/artifacts/abc123': { body: JSON.stringify(metadata), type: 'application/json' },
'/a/abc123': { body: '<p>no frame</p>', type: 'text/html' }
})
await expect(readArtifactContent('abc123', { apiUrl: api })).rejects.toThrow(
/exactly one content frame/
)
await new Promise<void>((resolve) => server!.close(() => resolve()))
const oversized = await serve({
'/v1/artifacts/abc123': { body: JSON.stringify(metadata), type: 'application/json' },
'/a/abc123': { body: '<iframe src="/usercontent/abc123/artifact.html">', type: 'text/html' },
'/usercontent/abc123/artifact.html': {
body: 'x'.repeat(ARTIFACT_CLI_MAX_READ_BYTES + 1),
type: 'text/html'
}
})
await expect(readArtifactContent('abc123', { apiUrl: oversized })).rejects.toThrow(/size limit/)
})
it('rejects unsupported content types and malformed ids before unsafe fetches', async () => {
const api = await serve({
'/v1/artifacts/abc123': { body: JSON.stringify(metadata), type: 'text/plain' }
})
await expect(readArtifactContent('../health', { apiUrl: api })).rejects.toThrow(/id is invalid/)
await expect(readArtifactContent('abc123', { apiUrl: api })).rejects.toThrow(/content type/)
})
it('rejects a content frame on an untrusted host', async () => {
const api = await serve({
'/v1/artifacts/abc123': { body: JSON.stringify(metadata), type: 'application/json' },
'/a/abc123': {
body: '<iframe src="https://evil.example/usercontent/abc123/artifact.html">',
type: 'text/html'
}
})
await expect(readArtifactContent('abc123', { apiUrl: api })).rejects.toThrow(
/allowed Orca content URL/
)
})
})
+247
View File
@@ -0,0 +1,247 @@
import {
ARTIFACT_CLI_MAX_READ_BYTES,
ARTIFACT_CLI_MAX_RPC_BYTES,
type ArtifactReadResult
} from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
import { artifactFetch, type ArtifactFetchOptions } from './artifact-cloud-request'
const SHARE_HOST = 'share.onorca.dev'
const CONTENT_HOST = 'content.orcausercontent.dev'
const TIMEOUT_MS = 20_000
export class ArtifactReadMetadataError extends OrcaCloudRequestError {
constructor(statusCode: number, errorCode?: string) {
super(statusCode, errorCode)
this.name = 'ArtifactReadMetadataError'
}
}
type ArtifactReadTarget = {
id: string
shareUrl: string
}
type ArtifactReadFetchOptions = {
apiUrl: string
token?: string
maxBytes?: number
fetchImpl?: (url: string, options?: ArtifactFetchOptions) => Promise<Response>
}
function isLoopback(hostname: string): boolean {
return ['127.0.0.1', 'localhost', '[::1]'].includes(hostname)
}
function validArtifactId(value: string): boolean {
return /^[A-Za-z0-9_-]{1,128}$/.test(value)
}
function parseTarget(input: string, apiUrl: string): ArtifactReadTarget {
const trimmed = input.trim()
if (!trimmed) {
throw new Error('Artifact id or share URL is required.')
}
if (!trimmed.includes('://')) {
if (!validArtifactId(trimmed)) {
throw new Error('Artifact id is invalid.')
}
return { id: trimmed, shareUrl: `${apiUrl}/a/${encodeURIComponent(trimmed)}` }
}
let url: URL
try {
url = new URL(trimmed)
} catch {
throw new Error('Artifact share URL is invalid.')
}
const apiOrigin = new URL(apiUrl)
const allowedOrigin = isLoopback(apiOrigin.hostname)
? url.origin === apiOrigin.origin
: url.origin === `https://${SHARE_HOST}`
if (!allowedOrigin) {
throw new Error('Artifact share URL must use the Orca share host.')
}
if (url.username || url.password || url.search || url.hash) {
throw new Error(
'Artifact share URL must not contain credentials, query parameters, or fragments.'
)
}
const match = /^\/a\/([^/]+)\/?$/.exec(url.pathname)
if (!match || !validArtifactId(match[1])) {
throw new Error('Artifact share URL path is invalid.')
}
return { id: match[1], shareUrl: `${url.origin}/a/${encodeURIComponent(match[1])}` }
}
async function readResponseText(response: Response, maxBytes: number): Promise<string> {
if (!response.body) {
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) {
throw new Error('Artifact content exceeds the CLI size limit.')
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
throw new Error('Artifact content is not valid UTF-8.')
}
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let bytes = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
bytes += value.byteLength
if (bytes > maxBytes) {
await reader.cancel()
throw new Error('Artifact content exceeds the CLI size limit.')
}
chunks.push(value)
}
} finally {
reader.releaseLock()
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(
Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))
)
} catch {
throw new Error('Artifact content is not valid UTF-8.')
}
}
async function fetchText(
url: string,
options: ArtifactReadFetchOptions,
expectedTypes: readonly string[],
includeToken = false
): Promise<string> {
const response = await (options.fetchImpl ?? artifactFetch)(url, {
token: includeToken ? options.token : undefined,
signal: AbortSignal.timeout(TIMEOUT_MS)
})
if (!response.ok) {
let code: string | undefined
try {
code = (JSON.parse(await readResponseText(response, 16 * 1024)) as { code?: unknown })
.code as string | undefined
} catch {
/* non-JSON error */
}
throw new OrcaCloudRequestError(response.status, code)
}
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (!contentType || !expectedTypes.includes(contentType)) {
throw new Error(`Unsupported artifact response content type: ${contentType ?? 'missing'}.`)
}
const contentLength = Number(response.headers.get('content-length'))
if (
Number.isFinite(contentLength) &&
contentLength > (options.maxBytes ?? ARTIFACT_CLI_MAX_READ_BYTES)
) {
await response.body?.cancel()
throw new Error('Artifact content exceeds the CLI size limit.')
}
return readResponseText(response, options.maxBytes ?? ARTIFACT_CLI_MAX_READ_BYTES)
}
function parseIframe(wrapper: string, id: string, apiUrl: string): string {
const matches = [...wrapper.matchAll(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)]
if (matches.length !== 1) {
throw new Error('Artifact share page did not contain exactly one content frame.')
}
let url: URL
try {
url = new URL(matches[0][1], apiUrl)
} catch {
throw new Error('Artifact content URL is invalid.')
}
const apiHost = new URL(apiUrl).hostname
const allowed = url.hostname === CONTENT_HOST || (isLoopback(apiHost) && url.origin === apiUrl)
if (
url.protocol !== new URL(apiUrl).protocol ||
!allowed ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error('Artifact content URL is not an allowed Orca content URL.')
}
const expectedPath = `/usercontent/${encodeURIComponent(id)}/artifact.html`
if (url.pathname !== expectedPath) {
throw new Error('Artifact content URL does not match the requested artifact.')
}
return url.toString()
}
export async function readArtifactContent(
input: string,
options: ArtifactReadFetchOptions
): Promise<ArtifactReadResult> {
const target = parseTarget(input, options.apiUrl)
let metadataText: string
try {
metadataText = await fetchText(
`${options.apiUrl}/v1/artifacts/${encodeURIComponent(target.id)}`,
options,
['application/json'],
true
)
} catch (error) {
if (error instanceof OrcaCloudRequestError) {
throw new ArtifactReadMetadataError(error.statusCode, error.errorCode)
}
throw error
}
let metadata: ArtifactReadResult['artifact']
let shareUrl = target.shareUrl
try {
const value = JSON.parse(metadataText) as {
artifact?: ArtifactReadResult['artifact']
shareUrl?: unknown
}
if (!value.artifact || typeof value.shareUrl !== 'string') {
throw new Error('Malformed artifact metadata.')
}
const metadataShareUrl = new URL(value.shareUrl)
if (
metadataShareUrl.username ||
metadataShareUrl.password ||
metadataShareUrl.search ||
metadataShareUrl.hash ||
metadataShareUrl.pathname !== new URL(target.shareUrl).pathname
) {
throw new Error('Malformed artifact metadata.')
}
if (
value.artifact.slug !== target.id ||
!['text/html', 'text/markdown'].includes(value.artifact.sourceContentType) ||
value.artifact.renderedContentType !== 'text/html'
) {
throw new Error('Malformed artifact metadata.')
}
metadata = value.artifact
shareUrl = target.shareUrl
} catch {
throw new Error('Artifact metadata response was malformed.')
}
const wrapper = await fetchText(target.shareUrl, options, ['text/html'], false)
const contentUrl = parseIframe(wrapper, target.id, options.apiUrl)
const content = await fetchText(contentUrl, options, ['text/html', 'text/markdown', 'text/plain'])
const result = {
artifact: metadata,
shareUrl,
contentType: metadata.renderedContentType,
content
}
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > ARTIFACT_CLI_MAX_RPC_BYTES) {
throw new Error('Artifact content exceeds the CLI transport size limit.')
}
return result
}
export { parseTarget }
+6
View File
@@ -205,6 +205,8 @@ import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-enve
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactReadRequest,
ArtifactReadResult,
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
@@ -6174,6 +6176,10 @@ export class OrcaRuntimeService {
return this.requireArtifactService().list(options)
}
readArtifact(request: ArtifactReadRequest): Promise<ArtifactCloudOperation<ArtifactReadResult>> {
return this.requireArtifactService().read(request)
}
getPublishedArtifactLink(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
@@ -21,6 +21,12 @@ function writeSchema(name: string) {
}
describe('artifact RPC schemas', () => {
it('registers bounded artifact reads', () => {
const schema = writeSchema('artifacts.read')
expect(schema.safeParse({ input: 'abc123' }).success).toBe(true)
expect(schema.safeParse({ input: '' }).success).toBe(false)
expect(schema.safeParse({ input: 'x'.repeat(2_049) }).success).toBe(false)
})
it('registers the local publish upsert', () => {
expect(writeSchema('artifacts.publish').safeParse(validRequest).success).toBe(true)
})
+10
View File
@@ -17,6 +17,11 @@ const ListOptions = z.object({
cursor: z.string().min(1).max(2_048).optional()
})
const ReadRequest = z.object({
input: z.string().min(1).max(2_048),
...CloudOptions
})
const SourceRequest = z.object({
sourceKey: z.string().min(1).max(32_768),
...CloudOptions
@@ -42,6 +47,11 @@ const WriteRequest = z
})
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'artifacts.read',
params: ReadRequest,
handler: (params, { runtime }) => runtime.readArtifact(params)
}),
defineMethod({
name: 'artifacts.list',
params: ListOptions,
+13
View File
@@ -3,6 +3,8 @@ export const ARTIFACT_MAX_CONTENT_BYTES = 5 * 1024 * 1024
/** Legacy CLI/SSH envelope cap; those transports still have ~1 MiB control frames. */
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
// Leave room in the RPC envelope for read metadata and JSON framing.
export const ARTIFACT_CLI_MAX_READ_BYTES = 768 * 1024
/** Allows JSON escaping while staying below the cloud API's 11 MiB body budget. */
export const ARTIFACT_MAX_REQUEST_BYTES = 11 * 1024 * 1024
@@ -63,6 +65,17 @@ export type ArtifactCloudOptions = {
authToken?: string
}
export type ArtifactReadRequest = ArtifactCloudOptions & {
input: string
}
export type ArtifactReadResult = {
artifact: ArtifactMetadata
shareUrl: string
contentType: string
content: string
}
export type ArtifactListOptions = ArtifactCloudOptions & {
cursor?: string
}