You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/05 07:58:55 UTC
(maka) 01/06: fix(artifacts): protect workflow-owned evidence from user deletion
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/4788-disposable-artifacts
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 0b3ba791009c4640ca3438393a250a1c3500dfe7
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Sep 5 15:42:14 2026 +0800
fix(artifacts): protect workflow-owned evidence from user deletion
---
.../workbar/tools/artifacts/artifact-pane.tsx | 8 ++++----
packages/core/src/__tests__/artifacts.test.ts | 11 +++++++++++
packages/core/src/artifacts.ts | 20 +++++++++++++-------
.../src/__tests__/artifact-protocol.test.ts | 5 ++---
.../src/__tests__/artifact-two-client-uds.test.ts | 21 +++++++++++++++------
packages/runtime-host/src/protocol/artifact.ts | 2 +-
packages/runtime-host/src/protocol/index.ts | 2 +-
.../runtime-host/src/server/artifact-coordinator.ts | 9 +++++++++
.../storage/src/__tests__/artifact-store.test.ts | 13 +++++++++----
packages/storage/src/artifact-store.ts | 3 +++
10 files changed, 68 insertions(+), 26 deletions(-)
diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
index 08db683a1d..aca5c1eaa2 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
@@ -60,7 +60,7 @@ import {
Copy,
Trash2,
} from '@maka/ui/icons';
-import type { ArtifactDescriptor, ArtifactKind } from '@maka/core/artifacts';
+import { canUserDeleteArtifact, type ArtifactDescriptor, type ArtifactKind } from '@maka/core/artifacts';
import type { UiLocale } from '@maka/core/ui-locale';
import { formatRelativeTimestamp } from '@maka/core/relative-time';
import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction';
@@ -353,6 +353,7 @@ export function ArtifactPane(props: {
async function deleteArtifact(artifactId: string) {
const actionSessionId = sessionId;
const record = activeRecords.find((entry) => entry.id === artifactId);
+ if (!record || !canUserDeleteArtifact(record)) return;
const name = record?.name ?? copy.pane.fallbackName;
const ok = await toast.confirm({
title: copy.pane.deleteTitle(name),
@@ -559,15 +560,14 @@ export function ArtifactPane(props: {
onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)),
}]
: []),
- { type: 'divider' as const },
- {
+ ...(canUserDeleteArtifact(previewRecord) ? [{ type: 'divider' as const }, {
label: copy.pane.delete,
icon: <Trash2 size={ICON_SIZE.control} aria-hidden="true" />,
onClick: () => void runArtifactAction(
`${previewRecord.id}:delete`,
() => deleteArtifact(previewRecord.id),
),
- },
+ }] : []),
]}
/>
</header>
diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts
index 370ee88d1c..93557757ce 100644
--- a/packages/core/src/__tests__/artifacts.test.ts
+++ b/packages/core/src/__tests__/artifacts.test.ts
@@ -22,6 +22,8 @@ import { describe, test } from 'node:test';
import {
ARTIFACT_ENTITY_ID_MAX_CHARS,
ARTIFACT_TURN_KEY_MAX_CHARS,
+ ARTIFACT_SOURCES,
+ canUserDeleteArtifact,
isArtifactChildResultOutput,
isArtifactSharedSessionReadable,
isArtifactUserVisible,
@@ -57,6 +59,15 @@ describe('Artifact turn key', () => {
});
describe('Artifact source policy', () => {
+ test('keeps workflow-owned evidence out of independent user deletion', () => {
+ for (const source of ARTIFACT_SOURCES) {
+ assert.equal(
+ canUserDeleteArtifact({ source }),
+ source === 'tool_result' || source === 'user_upload',
+ source,
+ );
+ }
+ });
test('includes produced outputs in child results without leaking internal artifacts', () => {
assert.equal(isArtifactChildResultOutput({ source: 'tool_result' }), true);
assert.equal(isArtifactChildResultOutput({ source: 'subagent_writeback' }), true);
diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts
index e904740bc8..48c058ca76 100644
--- a/packages/core/src/artifacts.ts
+++ b/packages/core/src/artifacts.ts
@@ -142,18 +142,19 @@ export interface ArtifactRecord extends ArtifactDescriptor {
}
interface ArtifactSourcePolicy {
+ readonly userDeletable: boolean;
readonly userVisible: boolean;
readonly sharedReadable: boolean;
}
const ARTIFACT_SOURCE_POLICIES = {
- tool_result: { userVisible: false, sharedReadable: true },
- tool_result_projection: { userVisible: false, sharedReadable: true },
- tool_result_archive: { userVisible: false, sharedReadable: false },
- subagent_writeback: { userVisible: true, sharedReadable: false },
- deep_research: { userVisible: true, sharedReadable: false },
- user_upload: { userVisible: false, sharedReadable: true },
- session_effect: { userVisible: false, sharedReadable: false },
+ tool_result: { userDeletable: true, userVisible: false, sharedReadable: true },
+ tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true },
+ tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false },
+ subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false },
+ deep_research: { userDeletable: false, userVisible: true, sharedReadable: false },
+ user_upload: { userDeletable: true, userVisible: false, sharedReadable: true },
+ session_effect: { userDeletable: false, userVisible: false, sharedReadable: false },
} as const satisfies Record<ArtifactSource, ArtifactSourcePolicy>;
const CHILD_RESULT_OUTPUT_SOURCES = new Set<ArtifactSource>([
@@ -167,6 +168,11 @@ export function isArtifactUserVisible(record: Pick<ArtifactRecord, 'source'>): b
return ARTIFACT_SOURCE_POLICIES[record.source].userVisible;
}
+/** Visibility does not grant permission to destroy evidence owned by a runtime workflow. */
+export function canUserDeleteArtifact(record: Pick<ArtifactRecord, 'source'>): boolean {
+ return ARTIFACT_SOURCE_POLICIES[record.source].userDeletable;
+}
+
export function isArtifactSharedSessionReadable(record: Pick<ArtifactRecord, 'source'>): boolean {
return ARTIFACT_SOURCE_POLICIES[record.source].sharedReadable;
}
diff --git a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
index f54a7fa659..906289e27f 100644
--- a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
@@ -255,9 +255,8 @@ describe('Artifact protocol', () => {
() => failure('artifact.query', 'operation_conflict', 'Protected runtime evidence'),
isInvalidFrame,
);
- assert.throws(
- () => failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'),
- isInvalidFrame,
+ assert.doesNotThrow(() =>
+ failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'),
);
assert.throws(() => failure('artifact.delete', 'outcome_unknown', 'Unknown'), isInvalidFrame);
});
diff --git a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
index 68fe3b7635..06b45444f4 100644
--- a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
@@ -182,11 +182,10 @@ test('production Host ignores Artifact publication residue and preserves deletes
for (const [index, artifact] of PROTECTED_ARTIFACTS.entries()) {
const client = index % 2 === 0 ? desktop : tui;
- const deleted = await client.request('artifact.delete', {
- sessionId,
- artifactId: artifact.id,
- });
- assert.deepEqual(deleted, { kind: 'deleted' });
+ await assert.rejects(
+ client.request('artifact.delete', { sessionId, artifactId: artifact.id }),
+ operationError('operation_conflict'),
+ );
}
const stale = await tui.request('artifact.query', {
@@ -218,7 +217,17 @@ test('production Host ignores Artifact publication residue and preserves deletes
successor = await startHost(root, capability.rootId);
const observer = await connectClient(root);
try {
- for (const artifactId of [...PROTECTED_ARTIFACTS.map(({ id }) => id), deleteA, deleteB]) {
+ for (const { id: artifactId } of PROTECTED_ARTIFACTS) {
+ assert.ok((await getArtifact(observer, sessionId, artifactId)).artifact);
+ const read = await observer.request('artifact.query', {
+ kind: 'read_text',
+ sessionId,
+ artifactId,
+ });
+ assert.equal(read.kind, 'text');
+ assert.ok(read.kind === 'text' && read.preview.ok);
+ }
+ for (const artifactId of [deleteA, deleteB]) {
const getResult = await getArtifact(observer, sessionId, artifactId);
const readResult = await observer.request('artifact.query', {
kind: 'read_text',
diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts
index c2bf3f7449..0c78f530ee 100644
--- a/packages/runtime-host/src/protocol/artifact.ts
+++ b/packages/runtime-host/src/protocol/artifact.ts
@@ -54,7 +54,7 @@ const QUERY_ERRORS = [
'not_found',
'persistence_failed',
] as const;
-const DELETE_ERRORS = QUERY_ERRORS;
+const DELETE_ERRORS = [...QUERY_ERRORS, 'operation_conflict'] as const;
const ARTIFACT_REQUIRED_FIELDS = [
'id',
'sessionId',
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index e6fd4a34fd..f93a4f1666 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 114 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 115 as const;
// 113: Artifacts are physically deleted and no longer expose tombstone status.
// 112: Owners can query the Host execution environment through an extensible,
// bounded resource-envelope contract. Older Hosts do not implement the query.
diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts
index 569346406a..9b507ba550 100644
--- a/packages/runtime-host/src/server/artifact-coordinator.ts
+++ b/packages/runtime-host/src/server/artifact-coordinator.ts
@@ -479,6 +479,15 @@ export class HostArtifactCoordinator {
error: { code: 'not_found', message: 'Artifact was not found' },
};
}
+ if (deleted.kind === 'protected') {
+ return {
+ ok: false,
+ error: {
+ code: 'operation_conflict',
+ message: 'Runtime-owned evidence cannot be deleted independently of its workflow',
+ },
+ };
+ }
return {
ok: true,
result: encodeArtifactDeleteResult({ kind: 'deleted' }),
diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts
index 0bfef725c0..e290c01c34 100644
--- a/packages/storage/src/__tests__/artifact-store.test.ts
+++ b/packages/storage/src/__tests__/artifact-store.test.ts
@@ -460,7 +460,7 @@ describe('SQLite Artifact store', () => {
});
});
- test('user delete physically removes every artifact source within its Session', async () => {
+ test('user deletion respects the current artifact source while owner cleanup remains possible', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
const { store } = authority;
@@ -475,13 +475,18 @@ describe('SQLite Artifact store', () => {
assert.equal(
(await store.deleteUserArtifactInSession(input.sessionId, input.id)).kind,
- 'deleted',
+ 'protected',
);
- assert.equal(await getArtifact(store, input.id), null);
+ assert.equal((await getArtifact(store, input.id))?.source, 'deep_research');
+ assert.deepEqual(await store.readTextInSession(input.sessionId, input.id), {
+ ok: true,
+ text: 'protected replacement',
+ });
assert.deepEqual(await store.deleteUserArtifactInSession('different-session', input.id), {
kind: 'not_found',
});
+ await store.purgeSessionArtifacts(input.sessionId);
assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), {
kind: 'not_found',
});
@@ -652,7 +657,7 @@ describe('SQLite Artifact store', () => {
const input = deepResearchArtifactInput('stable-revive', '# Revivable');
const store = createArtifactStore(root);
const first = await store.create(input);
- await store.deleteUserArtifactInSession(input.sessionId, first.id);
+ await store.deleteOwnedArtifactInSession(input.sessionId, first.id, input.source);
assert.equal(await getArtifact(store, first.id), null);
const recreated = await createArtifactStore(root).create(input);
diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts
index 7da9add065..c297bb62a6 100644
--- a/packages/storage/src/artifact-store.ts
+++ b/packages/storage/src/artifact-store.ts
@@ -42,6 +42,7 @@ import {
ArtifactRecord,
ArtifactSource,
ArtifactTextReadResult,
+ canUserDeleteArtifact,
isArtifactTurnKey,
isCanonicalArtifactEntityId,
} from '@maka/core/artifacts';
@@ -171,6 +172,7 @@ export interface DurableArtifactAttachmentReader {
export type ArtifactUserDeleteResult =
| { readonly kind: 'deleted' }
+ | { readonly kind: 'protected' }
| { readonly kind: 'not_found' };
export interface ArtifactAuthorityStore extends DurableArtifactAttachmentReader {
@@ -684,6 +686,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
const snapshot = this.sessionSnapshot(sessionId);
const existing = snapshot.records.find((record) => record.id === artifactId);
if (!existing) return { kind: 'not_found' };
+ if (!canUserDeleteArtifact(existing)) return { kind: 'protected' };
await this.purgeRecordsUnlocked([existing]);
return { kind: 'deleted' };
});
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org