fix(artifacts): recover committed cloud mutations (#13796)

This commit is contained in:
Jinwoo Hong
2026-08-11 12:13:16 -07:00
committed by GitHub
parent c96ded8dfd
commit 536d17dca3
13 changed files with 1671 additions and 48 deletions
@@ -0,0 +1,346 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: false },
safeStorage: { isEncryptionAvailable: () => false }
}))
import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = []
const apiUrl = 'http://localhost:3000'
const writeRequest = {
sourceKey: '/repo/report.html',
content: '<h1>Recovery</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
apiUrl,
authToken: 'token-a'
}
afterEach(async () => {
vi.unstubAllGlobals()
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('ArtifactCloudService committed response loss recovery', () => {
it('reconciles one remotely revocable artifact after a committed create loses its response', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
await expect(publishedLink(userDataPath)).resolves.toBeNull()
await expect(
service(userDataPath).publish({ ...writeRequest, content: '<h1>Changed after loss</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed after loss</h1>')
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
})
it('replays the exact create when content is unchanged after response loss', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
await expect(service(userDataPath).publish(writeRequest)).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe(writeRequest.content)
})
it('updates a recovered share when its content changed after response loss', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
await expect(
service(userDataPath).share({ ...writeRequest, content: '<h1>Changed share</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { artifact: { slug: 'artifact-1' } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed share</h1>')
})
it('retains recovery until a changed-content update succeeds', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
server.rejectNextUpdateStatus = 503
const changed = { ...writeRequest, content: '<h1>Changed after update failure</h1>' }
await expect(service(userDataPath).share(changed)).rejects.toMatchObject({ statusCode: 503 })
await expect(service(userDataPath).share(changed)).resolves.toMatchObject({
status: 'ok',
value: { artifact: { slug: 'artifact-1' } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
expect(server.artifactContent('artifact-1')).toBe(changed.content)
})
it('clears the durable mapping when a committed delete retry returns 404', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
vi.stubGlobal('fetch', server.fetch)
await service(userDataPath).publish(writeRequest)
server.loseNextDeleteResponse = true
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).rejects.toThrow('response lost')
expect(server.deleteMutations).toBe(1)
expect(server.artifactSlugs()).toEqual([])
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).resolves.toEqual({ status: 'ok', value: undefined })
expect(server.deleteMutations).toBe(1)
expect(server.artifactSlugs()).toEqual([])
await expect(publishedLink(userDataPath)).resolves.toBeNull()
})
it('keeps the durable mapping when a delete receives an unrelated 404', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
vi.stubGlobal('fetch', server.fetch)
await service(userDataPath).publish(writeRequest)
server.rejectNextDeleteCode = 'not_found'
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).rejects.toMatchObject({ statusCode: 404, errorCode: 'not_found' })
expect(server.deleteMutations).toBe(0)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
})
it('drops an uncommitted validation failure so corrected content can create', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.rejectNextCreateStatus = 422
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toMatchObject({
statusCode: 422
})
await expect(
service(userDataPath).publish({ ...writeRequest, content: '<h1>Corrected</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Corrected</h1>')
})
it('keeps a replay intent when validation changes after the original commit', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
server.rejectNextCreateStatus = 422
const changed = { ...writeRequest, content: '<h1>Changed after validation</h1>' }
await expect(service(userDataPath).publish(changed)).rejects.toMatchObject({ statusCode: 422 })
await expect(service(userDataPath).publish(changed)).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe(changed.content)
})
})
class ArtifactFaultServer {
readonly fetch = vi.fn(this.handle.bind(this))
createMutations = 0
deleteMutations = 0
loseNextCreateResponse = false
loseNextDeleteResponse = false
rejectNextCreateStatus: number | null = null
rejectNextDeleteCode: string | null = null
rejectNextUpdateStatus: number | null = null
private readonly artifacts = new Map<string, string>()
private readonly createsByKey = new Map<string, { body: string; response: object }>()
artifactSlugs(): string[] {
return [...this.artifacts.keys()].sort()
}
artifactContent(slug: string): string | undefined {
const body = this.artifacts.get(slug)
return body ? (JSON.parse(body) as { content?: string }).content : undefined
}
private async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const method = init?.method ?? 'GET'
if (method === 'POST') {
return this.create(init)
}
if (method === 'PUT') {
return this.update(String(input), init)
}
if (method === 'DELETE') {
return this.delete(String(input))
}
throw new Error(`Unexpected artifact request: ${method} ${String(input)}`)
}
private create(init?: RequestInit): Response {
if (this.rejectNextCreateStatus !== null) {
const status = this.rejectNextCreateStatus
this.rejectNextCreateStatus = null
return jsonResponse({ code: 'artifact_validation_failed' }, status)
}
const key = new Headers(init?.headers).get('idempotency-key')
if (new Headers(init?.headers).get('authorization') !== 'Bearer token-a') {
throw new Error('Missing artifact authorization')
}
if (!key) {
throw new Error('Missing idempotency key')
}
const body = String(init?.body)
const existing = this.createsByKey.get(key)
if (existing) {
if (existing.body !== body) {
throw new Error('Idempotency key reused with another request')
}
return jsonResponse(existing.response, 201)
}
this.createMutations += 1
const slug = `artifact-${this.createMutations}`
const response = createResponseBody(slug)
this.createsByKey.set(key, { body, response })
this.artifacts.set(slug, body)
if (this.loseNextCreateResponse) {
this.loseNextCreateResponse = false
throw new TypeError('response lost after committed create')
}
return jsonResponse(response, 201)
}
private delete(url: string): Response {
if (this.rejectNextDeleteCode !== null) {
const code = this.rejectNextDeleteCode
this.rejectNextDeleteCode = null
return jsonResponse({ code }, 404)
}
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
if (!this.artifacts.delete(slug)) {
return jsonResponse({ code: 'artifact_not_found' }, 404)
}
this.deleteMutations += 1
if (this.loseNextDeleteResponse) {
this.loseNextDeleteResponse = false
throw new TypeError('response lost after committed delete')
}
return new Response(null, { status: 204 })
}
private update(url: string, init?: RequestInit): Response {
if (this.rejectNextUpdateStatus !== null) {
const status = this.rejectNextUpdateStatus
this.rejectNextUpdateStatus = null
return jsonResponse({ code: 'artifact_update_failed' }, status)
}
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
if (!this.artifacts.has(slug)) {
return jsonResponse({ code: 'artifact_not_found' }, 404)
}
const headers = new Headers(init?.headers)
if (
headers.get('authorization') !== 'Bearer token-a' ||
headers.get('x-orca-edit-token') !== `edit-${slug}`
) {
return jsonResponse({ code: 'artifact_forbidden' }, 403)
}
this.artifacts.set(slug, String(init?.body))
return jsonResponse(createResponseBody(slug), 200)
}
}
async function createUserDataPath(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-recovery-'))
createdPaths.push(path)
return path
}
function service(userDataPath: string): ArtifactCloudService {
return new ArtifactCloudService(userDataPath, () => true)
}
async function publishedLink(userDataPath: string): Promise<string | null> {
const result = await service(userDataPath).getPublishedLink({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null
}
function jsonResponse(body: object, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
})
}
function createResponseBody(slug: string): object {
return {
artifact: {
version: 1,
slug,
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt: '2026-09-06T00:00:00.000Z',
byteSize: 17,
deletedAt: null
},
shareUrl: `https://share.onorca.dev/a/${slug}`,
editToken: `edit-${slug}`
}
}
+8 -1
View File
@@ -1,7 +1,14 @@
import type { ArtifactWriteRequest } from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
export type ArtifactWriteBody = {
content: string
contentType: ArtifactWriteRequest['contentType']
fileName: string
title?: string
}
export function artifactWriteBody(request: ArtifactWriteRequest): ArtifactWriteBody {
return {
content: request.content,
contentType: request.contentType,
+29 -7
View File
@@ -12,6 +12,7 @@ import type {
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 { prepareArtifactCloudUse } from '../orca-profiles/profile-artifact-cloud-cleanup'
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
import {
allowsArtifactCloudAuthOverride,
@@ -28,6 +29,7 @@ import {
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
import { ArtifactPublisher } from './artifact-publisher'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
type ArtifactAuthContext = {
profileId: string
@@ -35,6 +37,28 @@ 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')
}
@@ -227,10 +251,8 @@ export class ArtifactCloudService {
}
return this.publisher.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
method: 'DELETE',
editToken: record.editToken
})
await deleteArtifactRequest(apiUrl, token, `/${record.slug}`, record.editToken)
auth.assertCurrent()
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
sourceKey: request.sourceKey,
slug: record.slug
@@ -244,9 +266,8 @@ export class ArtifactCloudService {
return this.withAuth(options, (token, apiUrl, auth) =>
this.publisher.runForSlug(id, auth, async () => {
auth.assertCurrent()
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
method: 'DELETE'
})
await deleteArtifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`)
auth.assertCurrent()
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
})
)
@@ -258,6 +279,7 @@ export class ArtifactCloudService {
): Promise<ArtifactCloudOperation<T>> {
const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl)
const active = ensureActiveOrcaProfile(this.userDataPath)
prepareArtifactCloudUse(active.profile, this.userDataPath)
if (options.authToken?.trim()) {
if (!allowsArtifactCloudAuthOverride()) {
throw new Error(
@@ -0,0 +1,319 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts'
import {
MAX_ARTIFACT_CREATE_INTENT_BYTES,
MAX_PENDING_ARTIFACT_CREATES,
clearArtifactCreateIntents,
getArtifactCreateIntent,
getOrCreateArtifactCreateIntent,
removeArtifactCreateIntent
} from './artifact-create-intent-store'
import type { ArtifactShareScope } from './artifact-share-record-store'
vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() }))
const createdPaths: string[] = []
const scope: ArtifactShareScope = {
cloudUserId: 'user-a',
cloudProfileId: 'cloud-a',
cloudOrganizationId: 'org-a',
apiOrigin: 'https://share.onorca.dev'
}
const body = {
content: '<h1>Original</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
title: 'Original'
}
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('artifact create intent store', () => {
it('retains the first key and exact request until the matching create completes', async () => {
const userDataPath = await createUserDataPath()
const sourceKey = String.raw`C:\repo\report.html`
const first = getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
sourceKey,
scope,
'key-a',
body
)
const retry = getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
sourceKey,
scope,
'key-b',
{ ...body, content: '<h1>Changed</h1>' }
)
expect(first).toEqual(retry)
expect(retry).toMatchObject({ idempotencyKey: 'key-a', body })
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-b')
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).not.toBeNull()
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a')
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).toBeNull()
})
it.each([
['user', { cloudUserId: 'user-b' }],
['profile', { cloudProfileId: 'cloud-b' }],
['organization', { cloudOrganizationId: 'org-b' }],
['API origin', { apiOrigin: 'http://localhost:3000' }]
])('isolates recovery intent by %s', async (_name, changedScope) => {
const userDataPath = await createUserDataPath()
const sourceKey = '/repo/report.html'
getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a', body)
expect(
getArtifactCreateIntent('local-profile', userDataPath, sourceKey, {
...scope,
...changedScope
})
).toBeNull()
})
it('bounds unresolved payload storage without dropping an existing intent', async () => {
const userDataPath = await createUserDataPath()
for (let index = 0; index < MAX_PENDING_ARTIFACT_CREATES; index += 1) {
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
`/repo/report-${index}.html`,
scope,
`key-${index}`,
body
)
}
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/overflow.html',
scope,
'overflow-key',
body
)
).toThrow(/waiting for recovery/)
expect(
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report-0.html',
scope,
'replacement-key',
{ ...body, content: 'replacement' }
).idempotencyKey
).toBe('key-0')
})
it('clears pending content at the profile lifecycle boundary', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
clearArtifactCreateIntents('local-profile', userDataPath)
expect(
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toBeNull()
expect(await readdir(directory)).toEqual([])
})
it('removes crash-left temporary writes before admitting another intent', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
await writeFile(join(directory, 'crash-left.tmp'), 'partial')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/other.html',
scope,
'key-b',
body
)
expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false)
})
it('hardens one Windows journal directory without per-file PowerShell launches', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.mocked(execFileSync).mockImplementation((file) =>
String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : ''
)
try {
const userDataPath = await createUserDataPath()
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/other.html',
scope,
'key-b',
body
)
const powershellCalls = vi
.mocked(execFileSync)
.mock.calls.filter(([file]) => String(file).endsWith('powershell.exe'))
expect(powershellCalls).toHaveLength(1)
expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1')
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
vi.mocked(execFileSync).mockReset()
}
})
it('refuses to overwrite an unreadable matching intent', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await writeFile(join(directory, fileName), '{broken-json')
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-b',
body
)
).toThrow(/could not be read safely/)
})
it('removes an unreadable intent after its mutation completes', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await writeFile(join(directory, fileName), '{broken-json')
expect(() =>
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
).not.toThrow()
expect(await readdir(directory)).toEqual([])
})
it('rejects a persisted content type outside the artifact allowlist', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
const path = join(directory, fileName)
const intent = JSON.parse(await readFile(path, 'utf8')) as { body: { contentType: string } }
intent.body.contentType = 'application/octet-stream'
await writeFile(path, JSON.stringify(intent))
expect(() =>
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toThrow(/unsupported format/)
})
it('persists a valid artifact request near the RPC limit', async () => {
const userDataPath = await createUserDataPath()
const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) }
expect(
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody })
).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES)
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
nearLimitBody
)
).not.toThrow()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
const [fileName] = await readdir(directory)
expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES)
})
it('rejects an oversized recovery record before reading it', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await truncate(join(directory, fileName), MAX_ARTIFACT_CREATE_INTENT_BYTES + 1)
expect(() =>
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toThrow(/exceeds the supported size/)
})
})
async function createUserDataPath(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-'))
createdPaths.push(path)
return path
}
@@ -0,0 +1,255 @@
import { createHash, randomBytes } from 'node:crypto'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
import {
bestEffortFsyncDirectorySync,
fsyncFileSync,
hardenSecurePath
} from '../../shared/secure-file'
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
import type { ArtifactWriteBody } from './artifact-cloud-request'
import type { ArtifactShareScope } from './artifact-share-record-store'
export const MAX_PENDING_ARTIFACT_CREATES = 32
export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024
const MAX_HARDENED_INTENT_DIRECTORIES = 64
const hardenedIntentDirectories = new Set<string>()
export type ArtifactCreateIntent = {
version: 1
sourceKey: string
scope: ArtifactShareScope
idempotencyKey: string
body: ArtifactWriteBody
}
function intentDirectory(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-create-intents')
}
function ensureIntentDirectory(profileId: string, userDataPath: string): string {
const directory = intentDirectory(profileId, userDataPath)
mkdirSync(directory, { recursive: true, mode: 0o700 })
if (!hardenedIntentDirectories.has(directory)) {
hardenSecurePath(directory, {
isDirectory: true,
platform: process.platform,
sync: true
})
if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) {
const oldest = hardenedIntentDirectories.values().next().value
if (oldest !== undefined) {
hardenedIntentDirectories.delete(oldest)
}
}
hardenedIntentDirectories.add(directory)
}
return directory
}
function removeTemporaryIntents(directory: string): void {
for (const name of readdirSync(directory)) {
if (name.endsWith('.tmp')) {
rmSync(join(directory, name), { force: true })
}
}
}
function writeIntent(path: string, directory: string, serializedIntent: string): void {
// Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches.
const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
try {
writeFileSync(temporaryPath, serializedIntent, {
encoding: 'utf8',
mode: 0o600
})
fsyncFileSync(temporaryPath)
renameSync(temporaryPath, path)
bestEffortFsyncDirectorySync(directory)
} catch (error) {
rmSync(temporaryPath, { force: true })
throw error
}
}
function intentPath(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope
): string {
const identity = JSON.stringify([
sourceKey,
scope.cloudUserId,
scope.cloudProfileId,
scope.cloudOrganizationId,
scope.apiOrigin
])
const fileName = `${createHash('sha256').update(identity).digest('hex')}.json`
return join(intentDirectory(profileId, userDataPath), fileName)
}
function scopeMatches(left: ArtifactShareScope, right: ArtifactShareScope): boolean {
return (
left.cloudUserId === right.cloudUserId &&
left.cloudProfileId === right.cloudProfileId &&
left.cloudOrganizationId === right.cloudOrganizationId &&
left.apiOrigin === right.apiOrigin
)
}
function isWriteBody(value: unknown): value is ArtifactWriteBody {
if (!value || typeof value !== 'object') {
return false
}
const body = value as Partial<ArtifactWriteBody>
return (
typeof body.content === 'string' &&
(body.contentType === 'text/html' || body.contentType === 'text/markdown') &&
typeof body.fileName === 'string' &&
(body.title === undefined || typeof body.title === 'string')
)
}
function isScope(value: unknown): value is ArtifactShareScope {
if (!value || typeof value !== 'object') {
return false
}
const scope = value as Partial<ArtifactShareScope>
return [
scope.cloudUserId,
scope.cloudProfileId,
scope.cloudOrganizationId,
scope.apiOrigin
].every((field) => typeof field === 'string')
}
function readIntent(path: string): ArtifactCreateIntent {
let size: number
try {
size = statSync(path).size
} catch (error) {
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
}
if (size > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
throw new Error('Artifact create recovery record exceeds the supported size.')
}
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('Artifact create recovery record has an unsupported format.')
}
const intent = parsed as Partial<ArtifactCreateIntent>
if (
intent.version !== 1 ||
typeof intent.sourceKey !== 'string' ||
typeof intent.idempotencyKey !== 'string' ||
!intent.idempotencyKey ||
!isScope(intent.scope) ||
!isWriteBody(intent.body)
) {
throw new Error('Artifact create recovery record has an unsupported format.')
}
return intent as ArtifactCreateIntent
}
export function getArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope
): ArtifactCreateIntent | null {
const path = intentPath(profileId, userDataPath, sourceKey, scope)
if (!existsSync(path)) {
return null
}
const intent = readIntent(path)
if (intent.sourceKey !== sourceKey || !scopeMatches(intent.scope, scope)) {
throw new Error('Artifact create recovery record does not match its storage identity.')
}
return intent
}
export function getOrCreateArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope,
idempotencyKey: string,
body: ArtifactWriteBody
): ArtifactCreateIntent {
const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope)
if (existing) {
return existing
}
const directory = ensureIntentDirectory(profileId, userDataPath)
removeTemporaryIntents(directory)
const pendingCount = readdirSync(directory).filter((name) => name.endsWith('.json')).length
if (pendingCount >= MAX_PENDING_ARTIFACT_CREATES) {
throw new Error('Too many artifact creates are waiting for recovery. Retry an earlier share.')
}
const intent: ArtifactCreateIntent = {
version: 1,
sourceKey,
scope,
idempotencyKey,
body
}
const serializedIntent = JSON.stringify(intent, null, 2)
if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
throw new Error('Artifact create recovery record exceeds the supported size.')
}
writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent)
return intent
}
export function removeArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope,
expectedIdempotencyKey: string
): void {
const path = intentPath(profileId, userDataPath, sourceKey, scope)
if (!existsSync(path)) {
return
}
let matches = true
try {
matches = readIntent(path).idempotencyKey === expectedIdempotencyKey
} catch {
// An unreadable record cannot be replayed, so a completed mutation may discard it safely.
}
if (matches) {
rmSync(path, { force: true })
bestEffortFsyncDirectorySync(intentDirectory(profileId, userDataPath))
}
}
export function clearArtifactCreateIntents(profileId: string, userDataPath: string): void {
const directory = intentDirectory(profileId, userDataPath)
if (!existsSync(directory)) {
return
}
for (const name of readdirSync(directory)) {
if (name.endsWith('.json') || name.endsWith('.tmp')) {
rmSync(join(directory, name), { force: true })
}
}
bestEffortFsyncDirectorySync(directory)
}
+157 -30
View File
@@ -4,7 +4,17 @@ import type {
ArtifactWriteRequest
} from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
import {
artifactRequest,
type ArtifactWriteBody,
artifactWriteBody
} from './artifact-cloud-request'
import {
type ArtifactCreateIntent,
getArtifactCreateIntent,
getOrCreateArtifactCreateIntent,
removeArtifactCreateIntent
} from './artifact-create-intent-store'
import {
type ArtifactShareScope,
getArtifactShareRecord,
@@ -15,6 +25,30 @@ import {
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
type ArtifactCreateOutcome = {
editToken: string
intent: ArtifactCreateIntent
result: ArtifactPublishResult
}
function artifactWriteBodiesMatch(left: ArtifactWriteBody, right: ArtifactWriteBody): boolean {
return (
left.content === right.content &&
left.contentType === right.contentType &&
left.fileName === right.fileName &&
left.title === right.title
)
}
function discardsCreateIntent(error: unknown): boolean {
return (
error instanceof OrcaCloudRequestError &&
error.statusCode >= 400 &&
error.statusCode < 500 &&
![408, 409, 425, 429].includes(error.statusCode)
)
}
type ArtifactPublishAuthContext = {
profileId: string
scope: ArtifactShareScope
@@ -51,7 +85,23 @@ export class ArtifactPublisher {
): Promise<ArtifactListItem> {
return this.runForSource(request.sourceKey, auth, async () => {
auth.assertCurrent()
return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
const pending = getArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
if (pending && !artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
const item = await this.updateExisting(request, token, apiUrl, auth, {
slug: created.result.item.artifact.slug,
editToken: created.editToken
})
this.removeCreateIntent(request, auth, created.intent)
return item
}
this.removeCreateIntent(request, auth, created.intent)
return created.result.item
})
}
@@ -64,6 +114,25 @@ export class ArtifactPublisher {
): Promise<ArtifactPublishResult> {
return this.runForSource(request.sourceKey, auth, async () => {
auth.assertCurrent()
const pending = getArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
if (pending) {
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
if (artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
this.removeCreateIntent(request, auth, created.intent)
return created.result
}
const item = await this.updateExisting(request, token, apiUrl, auth, {
slug: created.result.item.artifact.slug,
editToken: created.editToken
})
this.removeCreateIntent(request, auth, created.intent)
return { change: 'created', item }
}
const record = getArtifactShareRecord(
auth.profileId,
this.userDataPath,
@@ -72,24 +141,8 @@ export class ArtifactPublisher {
)
if (record) {
try {
return await this.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
method: 'PUT',
editToken: record.editToken,
body: artifactWriteBody(request)
})
auth.assertCurrent()
refreshArtifactShareRecordExpiration(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
record,
item.artifact.expiresAt
)
return { change: 'updated', item }
})
const item = await this.updateExisting(request, token, apiUrl, auth, record)
return { change: 'updated', item }
} catch (error) {
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
throw error
@@ -101,7 +154,9 @@ export class ArtifactPublisher {
})
}
}
return this.create(request, token, apiUrl, auth, idempotencyKey)
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, null)
this.removeCreateIntent(request, auth, created.intent)
return created.result
})
}
@@ -121,18 +176,72 @@ export class ArtifactPublisher {
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
}
private updateExisting(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
record: { slug: string; editToken: string }
): Promise<ArtifactListItem> {
return this.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
method: 'PUT',
editToken: record.editToken,
body: artifactWriteBody(request)
})
auth.assertCurrent()
refreshArtifactShareRecordExpiration(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
record,
item.artifact.expiresAt
)
return item
})
}
private async create(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
idempotencyKey: string
): Promise<ArtifactPublishResult> {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: artifactWriteBody(request),
idempotencyKey
})
idempotencyKey: string,
pending: ArtifactCreateIntent | null
): Promise<ArtifactCreateOutcome> {
const replaying = pending !== null
const intent =
pending ??
getOrCreateArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
idempotencyKey,
artifactWriteBody(request)
)
let response: ArtifactCreateResponse
try {
response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: intent.body,
idempotencyKey: intent.idempotencyKey
})
} catch (error) {
// A replay may outlive its server receipt, so never abandon a possibly committed artifact.
if (!replaying && discardsCreateIntent(error)) {
removeArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
intent.idempotencyKey
)
}
throw error
}
auth.assertCurrent()
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
slug: response.artifact.slug,
@@ -142,11 +251,29 @@ export class ArtifactPublisher {
...auth.scope
})
return {
change: 'created',
item: { artifact: response.artifact, shareUrl: response.shareUrl }
editToken: response.editToken,
intent,
result: {
change: 'created',
item: { artifact: response.artifact, shareUrl: response.shareUrl }
}
}
}
private removeCreateIntent(
request: ArtifactWriteRequest,
auth: ArtifactPublishAuthContext,
intent: ArtifactCreateIntent
): void {
removeArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
intent.idempotencyKey
)
}
private async runSerialized<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(key) ?? Promise.resolve()
let release = (): void => {}
@@ -0,0 +1,88 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const fsyncMockState = vi.hoisted(() => ({
directoryDescriptor: -1,
directoryErrorCode: 'EINVAL'
}))
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
closeSync: (descriptor: number) => {
if (descriptor !== fsyncMockState.directoryDescriptor) {
actual.closeSync(descriptor)
}
},
fsyncSync: (descriptor: number) => {
if (descriptor === fsyncMockState.directoryDescriptor) {
throw Object.assign(new Error('directory fsync failed'), {
code: fsyncMockState.directoryErrorCode
})
}
return actual.fsyncSync(descriptor)
},
openSync: (path: string, flags: string | number) =>
actual.statSync(path).isDirectory()
? fsyncMockState.directoryDescriptor
: actual.openSync(path, flags)
}
})
import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file'
import {
clearArtifactCreateIntents,
getOrCreateArtifactCreateIntent
} from './artifact-create-intent-store'
const createdPaths: string[] = []
afterEach(() => {
fsyncMockState.directoryErrorCode = 'EINVAL'
for (const path of createdPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('skips directory fsync on Windows and propagates I/O failures elsewhere', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-eio-'))
createdPaths.push(directory)
fsyncMockState.directoryErrorCode = 'EIO'
const fsyncDirectory = (): void => bestEffortFsyncDirectorySync(directory)
if (process.platform === 'win32') {
expect(fsyncDirectory).not.toThrow()
} else {
expect(fsyncDirectory).toThrow('directory fsync failed')
}
})
it('keeps durable artifact records usable when directory fsync is unsupported', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-'))
createdPaths.push(userDataPath)
const recordPath = join(userDataPath, 'artifact-shares.json')
expect(() => writeDurableSecureJsonFile(recordPath, { ok: true })).not.toThrow()
expect(JSON.parse(readFileSync(recordPath, 'utf8'))).toEqual({ ok: true })
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
{
cloudUserId: 'user-a',
cloudProfileId: 'profile-a',
cloudOrganizationId: 'org-a',
apiOrigin: 'https://share.onorca.dev'
},
'key-a',
{ content: 'hello', contentType: 'text/markdown', fileName: 'report.md' }
)
).not.toThrow()
expect(() => clearArtifactCreateIntents('local-profile', userDataPath)).not.toThrow()
})
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFile } from '../../shared/secure-file'
import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file'
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
export type ArtifactShareScope = {
@@ -183,7 +183,7 @@ export function saveArtifactShareRecord(
const records = readRecords(profileId, userDataPath)
records.shares[sourceKey] = { ...record, savedAt: Date.now() }
records.shares = pruneRecords(records.shares, Date.now()).shares
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function refreshArtifactShareRecordExpiration(
@@ -228,7 +228,7 @@ export function removeArtifactShareRecords(
delete records.shares[sourceKey]
}
}
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function clearArtifactShareRecords(profileId: string, userDataPath: string): void {
@@ -238,7 +238,7 @@ export function clearArtifactShareRecords(profileId: string, userDataPath: strin
} catch {
// Clearing must recover sign-out from an unreadable token index.
}
writeSecureJsonFile(recordPath(profileId, userDataPath), {
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), {
version: 2,
lifecycleGeneration: lifecycleGeneration + 1,
lifecycleNonce: randomUUID(),
@@ -0,0 +1,244 @@
import { mkdirSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles'
import type * as ArtifactCreateIntentStore from '../artifacts/artifact-create-intent-store'
import type * as ProfileArtifactCloudCleanup from './profile-artifact-cloud-cleanup'
import type * as ProfileIndexStore from './profile-index-store'
vi.mock('../artifacts/artifact-create-intent-store', async () => {
const actual = await vi.importActual<typeof ArtifactCreateIntentStore>(
'../artifacts/artifact-create-intent-store'
)
return { ...actual, clearArtifactCreateIntents: vi.fn(actual.clearArtifactCreateIntents) }
})
vi.mock('./profile-index-store', async () => {
const actual = await vi.importActual<typeof ProfileIndexStore>('./profile-index-store')
return { ...actual, writeProfileIndex: vi.fn(actual.writeProfileIndex) }
})
vi.mock('./profile-artifact-cloud-cleanup', async () => {
const actual = await vi.importActual<typeof ProfileArtifactCloudCleanup>(
'./profile-artifact-cloud-cleanup'
)
return {
...actual,
commitArtifactCloudCleanup: vi.fn(actual.commitArtifactCloudCleanup)
}
})
import {
clearArtifactCreateIntents,
getArtifactCreateIntent,
getOrCreateArtifactCreateIntent
} from '../artifacts/artifact-create-intent-store'
import type { ArtifactShareScope } from '../artifacts/artifact-share-record-store'
import {
artifactCloudCleanupNeedsCommit,
commitArtifactCloudCleanup,
prepareArtifactCloudCleanup,
prepareArtifactCloudUse
} from './profile-artifact-cloud-cleanup'
import { linkOrcaProfileToCloud, unlinkOrcaProfileFromCloud } from './profile-cloud-index'
import {
getOrcaProfileIndexPath,
getOrcaProfileDirectory,
loadOrCreateProfileIndex,
readProfileIndex,
writeProfileIndex
} from './profile-index-store'
const createdPaths: string[] = []
const profileId = 'local-default'
afterEach(async () => {
vi.clearAllMocks()
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('profile artifact cloud cleanup', () => {
it('preserves recovery state when the profile write fails', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const scope = shareScope('org-a')
createIntent(userDataPath, scope)
vi.mocked(writeProfileIndex).mockImplementationOnce(() => {
throw new Error('profile write failed')
})
expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow(
'profile write failed'
)
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull()
linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull()
})
it('retries cleanup after the profile transition commits', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const scope = shareScope('org-a')
createIntent(userDataPath, scope)
vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => {
throw new Error('cleanup failed')
})
expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow(
'cleanup failed'
)
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b')
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull()
linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull()
})
it('reconciles an interrupted transition before linking again', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const scope = shareScope('org-a')
createIntent(userDataPath, scope)
interruptNextCleanupCommit()
expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow(
'cleanup commit interrupted'
)
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b')
vi.mocked(writeProfileIndex).mockClear()
vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => {
throw new Error('cleanup failed')
})
expect(() => linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath)).toThrow(
'cleanup failed'
)
expect(writeProfileIndex).not.toHaveBeenCalled()
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b')
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull()
linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath)
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-c')
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull()
})
it('reconciles an interrupted transition before unlinking', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const scope = shareScope('org-a')
createIntent(userDataPath, scope)
interruptNextCleanupCommit()
expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow(
'cleanup commit interrupted'
)
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b')
vi.mocked(writeProfileIndex).mockClear()
vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => {
throw new Error('cleanup failed')
})
expect(() => unlinkOrcaProfileFromCloud(profileId, userDataPath)).toThrow('cleanup failed')
expect(writeProfileIndex).not.toHaveBeenCalled()
expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b')
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull()
unlinkOrcaProfileFromCloud(profileId, userDataPath)
expect(currentCloud(userDataPath)).toBeUndefined()
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull()
})
it('preserves an orphaned local marker for an unknown profile', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const orphanProfileId = 'missing-profile'
const scope = shareScope('org-a')
mkdirSync(getOrcaProfileDirectory(orphanProfileId, userDataPath), { recursive: true })
createIntent(userDataPath, scope, orphanProfileId)
prepareArtifactCloudCleanup(orphanProfileId, userDataPath, undefined)
const transitions = [
() => linkOrcaProfileToCloud(orphanProfileId, cloud('org-b'), userDataPath),
() => unlinkOrcaProfileFromCloud(orphanProfileId, userDataPath)
]
for (const transition of transitions) {
expect(transition).toThrow('unknown_orca_profile')
expect(artifactCloudCleanupNeedsCommit(orphanProfileId, userDataPath, undefined)).toBe(true)
expect(
getArtifactCreateIntent(orphanProfileId, userDataPath, '/report.md', scope)
).not.toBeNull()
}
})
it('cleans old recovery state when the active organization changes', async () => {
const userDataPath = await createLinkedProfile(cloud('org-a'))
const scope = shareScope('org-a')
createIntent(userDataPath, scope)
linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)
expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull()
})
it('blocks artifact use until a visible transition is durably committed', async () => {
const cloudSummary = cloud('org-a')
const userDataPath = await createLinkedProfile(cloudSummary)
prepareArtifactCloudCleanup(profileId, userDataPath, cloudSummary)
expect(() =>
prepareArtifactCloudUse({ id: profileId, cloud: cloudSummary }, userDataPath)
).toThrow(/transition must be retried/)
})
})
function cloud(activeOrgId: string): OrcaProfileCloudSummary {
return {
cloudProfileId: 'cloud-profile-a',
userId: 'user-a',
email: 'user@example.com',
activeOrgId,
linkedAt: 1
}
}
function shareScope(cloudOrganizationId: string): ArtifactShareScope {
return {
cloudUserId: 'user-a',
cloudProfileId: 'cloud-profile-a',
cloudOrganizationId,
apiOrigin: 'https://share.onorca.dev'
}
}
async function createLinkedProfile(cloudSummary: OrcaProfileCloudSummary): Promise<string> {
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-profile-artifact-cleanup-'))
createdPaths.push(userDataPath)
loadOrCreateProfileIndex(userDataPath)
linkOrcaProfileToCloud(profileId, cloudSummary, userDataPath)
vi.clearAllMocks()
return userDataPath
}
function createIntent(
userDataPath: string,
scope: ArtifactShareScope,
targetProfileId = profileId
): void {
getOrCreateArtifactCreateIntent(targetProfileId, userDataPath, '/report.md', scope, 'key-a', {
content: '# report',
contentType: 'text/markdown',
fileName: 'report.md'
})
}
function currentCloud(userDataPath: string): OrcaProfileCloudSummary | undefined {
return readProfileIndex(getOrcaProfileIndexPath(userDataPath))?.profiles.find(
(profile) => profile.id === profileId
)?.cloud
}
function interruptNextCleanupCommit(): void {
vi.mocked(commitArtifactCloudCleanup).mockImplementationOnce(() => {
throw new Error('cleanup commit interrupted')
})
}
@@ -0,0 +1,131 @@
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import type { OrcaProfileCloudSummary, OrcaProfileSummary } from '../../shared/orca-profiles'
import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file'
import { clearArtifactCreateIntents } from '../artifacts/artifact-create-intent-store'
import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store'
import { getOrcaProfileDirectory } from './profile-storage-paths'
type ArtifactCloudCleanupMarker = {
version: 1
phase: 'prepared' | 'committed'
targetIdentity: string
}
function cleanupMarkerPath(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-cloud-cleanup.json')
}
export function artifactCloudIdentity(cloud: OrcaProfileCloudSummary | undefined): string {
return cloud
? JSON.stringify([cloud.userId, cloud.cloudProfileId, cloud.activeOrgId ?? ''])
: 'local'
}
export function prepareArtifactCloudCleanup(
profileId: string,
userDataPath: string,
targetCloud: OrcaProfileCloudSummary | undefined
): void {
writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), {
version: 1,
phase: 'prepared',
targetIdentity: artifactCloudIdentity(targetCloud)
} satisfies ArtifactCloudCleanupMarker)
}
function readCleanupMarker(
profileId: string,
userDataPath: string
): ArtifactCloudCleanupMarker | null {
const path = cleanupMarkerPath(profileId, userDataPath)
if (!existsSync(path)) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error('Artifact cloud cleanup marker could not be read safely.', { cause: error })
}
if (
!parsed ||
typeof parsed !== 'object' ||
(parsed as Partial<ArtifactCloudCleanupMarker>).version !== 1 ||
!['prepared', 'committed'].includes(
(parsed as Partial<ArtifactCloudCleanupMarker>).phase ?? ''
) ||
typeof (parsed as Partial<ArtifactCloudCleanupMarker>).targetIdentity !== 'string'
) {
throw new Error('Artifact cloud cleanup marker has an unsupported format.')
}
return parsed as ArtifactCloudCleanupMarker
}
export function artifactCloudCleanupNeedsCommit(
profileId: string,
userDataPath: string,
targetCloud: OrcaProfileCloudSummary | undefined
): boolean {
const marker = readCleanupMarker(profileId, userDataPath)
return (
marker?.phase === 'prepared' && marker.targetIdentity === artifactCloudIdentity(targetCloud)
)
}
export function commitArtifactCloudCleanup(
profileId: string,
userDataPath: string,
targetCloud: OrcaProfileCloudSummary | undefined
): void {
const targetIdentity = artifactCloudIdentity(targetCloud)
const marker = readCleanupMarker(profileId, userDataPath)
if (marker?.phase !== 'prepared' || marker.targetIdentity !== targetIdentity) {
throw new Error('Artifact cloud cleanup marker does not match the profile transition.')
}
writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), {
version: 1,
phase: 'committed',
targetIdentity
} satisfies ArtifactCloudCleanupMarker)
}
export function completeArtifactCloudCleanupIfCommitted(
profileId: string,
userDataPath: string,
currentCloud: OrcaProfileCloudSummary | undefined
): void {
const marker = readCleanupMarker(profileId, userDataPath)
if (
marker?.phase !== 'committed' ||
marker.targetIdentity !== artifactCloudIdentity(currentCloud)
) {
return
}
clearArtifactCreateIntents(profileId, userDataPath)
clearArtifactShareRecords(profileId, userDataPath)
rmSync(cleanupMarkerPath(profileId, userDataPath), { force: true })
bestEffortFsyncDirectorySync(getOrcaProfileDirectory(profileId, userDataPath))
}
function assertArtifactCloudCleanupReady(
profileId: string,
userDataPath: string,
currentCloud: OrcaProfileCloudSummary | undefined
): void {
const marker = readCleanupMarker(profileId, userDataPath)
if (
marker?.phase === 'prepared' &&
marker.targetIdentity === artifactCloudIdentity(currentCloud)
) {
throw new Error('The Orca profile transition must be retried before publishing artifacts.')
}
}
export function prepareArtifactCloudUse(
profile: Pick<OrcaProfileSummary, 'id' | 'cloud'>,
userDataPath: string
): void {
completeArtifactCloudCleanupIfCommitted(profile.id, userDataPath, profile.cloud)
assertArtifactCloudCleanupReady(profile.id, userDataPath, profile.cloud)
}
+41 -5
View File
@@ -11,7 +11,12 @@ import {
loadOrCreateProfileIndex,
writeProfileIndex
} from './profile-index-store'
import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store'
import {
artifactCloudCleanupNeedsCommit,
commitArtifactCloudCleanup,
completeArtifactCloudCleanupIfCommitted,
prepareArtifactCloudCleanup
} from './profile-artifact-cloud-cleanup'
export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & {
profile: OrcaProfileSummary
@@ -50,6 +55,19 @@ function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSu
}
}
function reconcileCurrentArtifactCloudCleanup(
profileId: string,
userDataPath: string,
currentCloud: OrcaProfileCloudSummary | undefined
): void {
completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud)
if (!artifactCloudCleanupNeedsCommit(profileId, userDataPath, currentCloud)) {
return
}
commitArtifactCloudCleanup(profileId, userDataPath, currentCloud)
completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud)
}
export function createCloudLinkedOrcaProfileRecord(
cloud: OrcaProfileCloudSummary,
args: { name?: string },
@@ -92,6 +110,12 @@ export function linkOrcaProfileToCloud(
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const currentProfile = index.profiles.find((profile) => profile.id === profileId)
if (!currentProfile) {
throw new Error('unknown_orca_profile')
}
reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud)
const cleanupNeedsCommit = artifactCloudCleanupNeedsCommit(profileId, userDataPath, cloud)
const now = Date.now()
let found = false
let cloudIdentityChanged = false
@@ -103,21 +127,26 @@ export function linkOrcaProfileToCloud(
cloudIdentityChanged = Boolean(
profile.cloud &&
(profile.cloud.userId !== cloud.userId ||
profile.cloud.cloudProfileId !== cloud.cloudProfileId)
profile.cloud.cloudProfileId !== cloud.cloudProfileId ||
(profile.cloud.activeOrgId ?? '') !== (cloud.activeOrgId ?? ''))
)
return toCloudLinkedProfile(profile, cloud, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
if (cloudIdentityChanged) {
clearArtifactShareRecords(profileId, userDataPath)
if (cloudIdentityChanged || cleanupNeedsCommit) {
prepareArtifactCloudCleanup(profileId, userDataPath, cloud)
}
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
if (cloudIdentityChanged || cleanupNeedsCommit) {
commitArtifactCloudCleanup(profileId, userDataPath, cloud)
completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, cloud)
}
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
@@ -129,6 +158,11 @@ export function unlinkOrcaProfileFromCloud(
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const currentProfile = index.profiles.find((profile) => profile.id === profileId)
if (!currentProfile) {
throw new Error('unknown_orca_profile')
}
reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
@@ -141,12 +175,14 @@ export function unlinkOrcaProfileFromCloud(
if (!found) {
throw new Error('unknown_orca_profile')
}
clearArtifactShareRecords(profileId, userDataPath)
prepareArtifactCloudCleanup(profileId, userDataPath, undefined)
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
commitArtifactCloudCleanup(profileId, userDataPath, undefined)
completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, undefined)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
@@ -8,6 +8,7 @@ import {
} from 'node:fs'
import { randomUUID } from 'node:crypto'
import { dirname } from 'node:path'
import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file'
import type { GlobalSettings } from '../../shared/types'
import {
createDefaultLocalOrcaProfile,
@@ -130,7 +131,9 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v
}
const tmpPath = `${indexPath}.tmp`
writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8')
fsyncFileSync(tmpPath)
renameSync(tmpPath, indexPath)
bestEffortFsyncDirectorySync(dirname(indexPath))
}
function copyIfPresent(source: string, target: string): void {
+46 -1
View File
@@ -1,8 +1,11 @@
import { randomBytes } from 'node:crypto'
import {
chmodSync,
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
renameSync,
rmSync,
statSync,
@@ -40,6 +43,8 @@ const DEFAULT_HARDENING_CACHE_BOUNDS: SecurePathHardeningCacheBounds = {
maxTotalKeyBytes: SECURE_PATH_HARDENING_CACHE_KEYS_MAX_BYTES
}
const UNSUPPORTED_DIRECTORY_FSYNC_CODES = new Set(['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'])
// Why: PowerShell hardening (~1-1.5s) stalls the main thread, so cache idempotent re-hardens per process.
let hardenedPathsThisProcess = new SecurePathHardeningCache<HardenedPathCacheEntry>(
DEFAULT_HARDENING_CACHE_BOUNDS
@@ -87,7 +92,15 @@ export function writeSecureJsonFile(targetPath: string, value: unknown): void {
writeSecureFile(targetPath, JSON.stringify(value, null, 2))
}
export function writeSecureFile(targetPath: string, contents: string): void {
export function writeDurableSecureJsonFile(targetPath: string, value: unknown): void {
writeSecureFile(targetPath, JSON.stringify(value, null, 2), { durable: true })
}
export function writeSecureFile(
targetPath: string,
contents: string,
options: { durable?: boolean } = {}
): void {
const dir = dirname(targetPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -101,6 +114,9 @@ export function writeSecureFile(targetPath: string, contents: string): void {
encoding: 'utf-8',
mode: 0o600
})
if (options.durable) {
fsyncFileSync(tmpFile)
}
// Why: writeFileSync mode is a no-op on Windows, so restrict the credential's ACL synchronously before the rename publishes it under inherited ACLs.
applySecurePathRestriction(tmpFile, false, process.platform, true)
renameSync(tmpFile, targetPath)
@@ -108,12 +124,41 @@ export function writeSecureFile(targetPath: string, contents: string): void {
if (applySecurePathRestriction(targetPath, false, process.platform, true)) {
rememberHardenedPath(targetPath, false)
}
if (options.durable) {
bestEffortFsyncDirectorySync(dir)
}
} catch (error) {
rmSync(tmpFile, { force: true })
throw error
}
}
export function fsyncFileSync(path: string): void {
const descriptor = openSync(path, 'r')
try {
fsyncSync(descriptor)
} finally {
closeSync(descriptor)
}
}
export function bestEffortFsyncDirectorySync(directory: string): void {
if (process.platform === 'win32') {
return
}
try {
fsyncFileSync(directory)
} catch (error) {
if (
error instanceof Error &&
UNSUPPORTED_DIRECTORY_FSYNC_CODES.has((error as NodeJS.ErrnoException).code ?? '')
) {
return
}
throw error
}
}
export function hardenExistingSecureFile(targetPath: string): void {
const dir = dirname(targetPath)
if (existsSync(dir)) {