mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
feat(cli): read shared artifacts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -25,6 +25,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
{
|
||||
name: 'artifacts',
|
||||
keys: [
|
||||
'artifacts read',
|
||||
'artifacts list',
|
||||
'artifacts share',
|
||||
'artifacts update',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user