@nanhara/hara 0.127.2 → 0.128.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +7 -0
- package/dist/artifacts/store.js +530 -0
- package/dist/security/private-state.js +94 -1
- package/dist/serve/protocol.js +4 -0
- package/dist/serve/server.js +67 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.128.0 — 2026-07-20 — local deliverable foundation
|
|
9
|
+
|
|
10
|
+
- Hara Serve now provides the first `artifact/1` local runtime through authenticated
|
|
11
|
+
`artifact.import`, `artifact.list`, `artifact.get`, and `artifact.revisions` methods. Presentations,
|
|
12
|
+
spreadsheets, and documents receive opaque Artifact/revision identities and a stable content receipt.
|
|
13
|
+
- Import is copy-on-write and leaves the selected file untouched. The private store rejects relative paths,
|
|
14
|
+
symbolic links, hard links, protected credential/configuration files, macro-enabled Office extensions,
|
|
15
|
+
type-confused Office packages, empty files, and files over 64 MiB; activation and immutable binary writes
|
|
16
|
+
are crash-safe, owner-only, and digest-verified.
|
|
17
|
+
- Local metadata never retains the selected absolute path. Corrupt entries remain hidden without hiding
|
|
18
|
+
healthy work, and unexpected filesystem failures cross the Desktop RPC boundary as path-free errors.
|
|
19
|
+
- This release intentionally stores and verifies deliverables but does not render, edit, export, or execute
|
|
20
|
+
them. Those actions remain gated on a matching reviewed Office capability in the next vertical slices.
|
|
21
|
+
- Upgrade with `npm i -g @nanhara/hara@0.128.0`.
|
|
22
|
+
|
|
8
23
|
## 0.127.2 — 2026-07-20 — quiet scheduled monitoring
|
|
9
24
|
|
|
10
25
|
- Cron delivery now supports `--deliver-mode always|on-output|on-error`. Existing jobs retain the
|
package/README.md
CHANGED
|
@@ -272,6 +272,13 @@ Type-ahead Enter steers the exact live turn; `/next <message>` queues a separate
|
|
|
272
272
|
clients use `session.steer` with `expectedTurnId` (durable before ACK) and may pass `newTask: true` to force a
|
|
273
273
|
new execution. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
|
|
274
274
|
installs.
|
|
275
|
+
**Local deliverables**: Hara Serve 0.128 adds the first local `artifact/1` foundation for presentations,
|
|
276
|
+
spreadsheets, and documents. An authenticated Desktop client can import, list, integrity-check, and inspect
|
|
277
|
+
version history through `artifact.import|list|get|revisions`. Import makes an owner-only immutable copy under
|
|
278
|
+
`~/.hara/artifacts`, never changes the selected file, and never stores its absolute source path. Relative,
|
|
279
|
+
linked, protected, macro-enabled, type-confused, empty, and oversized inputs fail closed. This foundation
|
|
280
|
+
does **not** render, edit, export, or execute imported content; those operations require a matching reviewed
|
|
281
|
+
Office capability.
|
|
275
282
|
**MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch). Hara starts with every configured server stopped and exposes only `mcp_connect`; when the current task first needs one server, the agent requests permission to connect that server and its tools appear on the next round as `mcp__<server>__<tool>`. Unrelated servers remain stopped. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive external-tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
|
|
276
283
|
**Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
|
|
277
284
|
**Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/WeChat/webhook) for outcomes. `--deliver-mode always` preserves the existing every-run heartbeat, `on-output` sends only when redacted stdout is non-empty, and `on-error` sends only failed runs; `--alert-after N` remains independent and still raises the consecutive-failure 🚨 threshold alarm (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, realpathSync, renameSync, } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
5
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
6
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
7
|
+
import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
8
|
+
export const ARTIFACT_PROTOCOL_VERSION = "artifact/1";
|
|
9
|
+
export const MAX_ARTIFACT_IMPORT_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const MAX_ARTIFACTS = 10_000;
|
|
11
|
+
const MAX_REVISIONS = 10_000;
|
|
12
|
+
const JSON_LIMIT = 512 * 1024;
|
|
13
|
+
const ARTIFACT_ID = /^art_[a-f0-9]{32}$/;
|
|
14
|
+
const REVISION_ID = /^rev_[a-f0-9]{32}$/;
|
|
15
|
+
const SAFE_CONTENT_REF = /^content\.[a-z0-9]{1,12}$/;
|
|
16
|
+
export class ArtifactStoreError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message, options) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = "ArtifactStoreError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const FORMATS = new Map([
|
|
25
|
+
[".pptx", { kind: "presentation", mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }],
|
|
26
|
+
[".ppt", { kind: "presentation", mediaType: "application/vnd.ms-powerpoint" }],
|
|
27
|
+
[".odp", { kind: "presentation", mediaType: "application/vnd.oasis.opendocument.presentation" }],
|
|
28
|
+
[".xlsx", { kind: "spreadsheet", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }],
|
|
29
|
+
[".xls", { kind: "spreadsheet", mediaType: "application/vnd.ms-excel" }],
|
|
30
|
+
[".csv", { kind: "spreadsheet", mediaType: "text/csv" }],
|
|
31
|
+
[".ods", { kind: "spreadsheet", mediaType: "application/vnd.oasis.opendocument.spreadsheet" }],
|
|
32
|
+
[".docx", { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }],
|
|
33
|
+
[".doc", { kind: "document", mediaType: "application/msword" }],
|
|
34
|
+
[".md", { kind: "document", mediaType: "text/markdown" }],
|
|
35
|
+
[".txt", { kind: "document", mediaType: "text/plain" }],
|
|
36
|
+
[".rtf", { kind: "document", mediaType: "application/rtf" }],
|
|
37
|
+
[".odt", { kind: "document", mediaType: "application/vnd.oasis.opendocument.text" }],
|
|
38
|
+
]);
|
|
39
|
+
const MACRO_FORMATS = new Set([".pptm", ".ppsm", ".xlsm", ".xltm", ".docm", ".dotm"]);
|
|
40
|
+
const ZIP_FORMATS = new Set([".pptx", ".odp", ".xlsx", ".ods", ".docx", ".odt"]);
|
|
41
|
+
const COMPOUND_FORMATS = new Set([".ppt", ".xls", ".doc"]);
|
|
42
|
+
const newOpaqueId = (prefix) => `${prefix}_${randomUUID().replaceAll("-", "")}`;
|
|
43
|
+
const storeError = (code, message, cause) => new ArtifactStoreError(code, message, cause === undefined ? undefined : { cause });
|
|
44
|
+
function verifyDirectory(identity) {
|
|
45
|
+
const info = lstatSync(identity.path);
|
|
46
|
+
if (!info.isDirectory()
|
|
47
|
+
|| info.isSymbolicLink()
|
|
48
|
+
|| info.dev !== identity.dev
|
|
49
|
+
|| info.ino !== identity.ino
|
|
50
|
+
|| realpathSync.native(identity.path) !== identity.path)
|
|
51
|
+
throw storeError("ARTIFACT_CORRUPT", "private artifact directory identity changed");
|
|
52
|
+
}
|
|
53
|
+
function artifactRoot(home) {
|
|
54
|
+
return ensurePrivateStateSubdirectory(home, [".hara", "artifacts"]);
|
|
55
|
+
}
|
|
56
|
+
function parseJsonFile(path) {
|
|
57
|
+
const snapshot = readPrivateStateFileSnapshotSync(path, JSON_LIMIT);
|
|
58
|
+
if (!snapshot)
|
|
59
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(snapshot.text);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function plainRecord(value) {
|
|
68
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
69
|
+
}
|
|
70
|
+
function exactKeys(value, required, optional) {
|
|
71
|
+
const allowed = new Set([...required, ...optional]);
|
|
72
|
+
return required.every((key) => Object.hasOwn(value, key))
|
|
73
|
+
&& Object.keys(value).every((key) => allowed.has(key));
|
|
74
|
+
}
|
|
75
|
+
function isArtifactLockRef(value) {
|
|
76
|
+
if (!plainRecord(value) || !exactKeys(value, ["id", "version", "sha256"], []))
|
|
77
|
+
return false;
|
|
78
|
+
return typeof value.id === "string"
|
|
79
|
+
&& value.id.length >= 1
|
|
80
|
+
&& value.id.length <= 256
|
|
81
|
+
&& typeof value.version === "string"
|
|
82
|
+
&& value.version.length >= 1
|
|
83
|
+
&& value.version.length <= 128
|
|
84
|
+
&& typeof value.sha256 === "string"
|
|
85
|
+
&& /^[a-f0-9]{64}$/.test(value.sha256);
|
|
86
|
+
}
|
|
87
|
+
function isArtifactRecord(value) {
|
|
88
|
+
if (!plainRecord(value) || !exactKeys(value, ["protocol", "artifactId", "kind", "title", "currentRevisionId"], ["origin", "dataResidency", "capabilityLock", "templateLock"]))
|
|
89
|
+
return false;
|
|
90
|
+
return value.protocol === ARTIFACT_PROTOCOL_VERSION
|
|
91
|
+
&& typeof value.artifactId === "string"
|
|
92
|
+
&& ARTIFACT_ID.test(value.artifactId)
|
|
93
|
+
&& (value.kind === "presentation" || value.kind === "spreadsheet" || value.kind === "document")
|
|
94
|
+
&& typeof value.title === "string"
|
|
95
|
+
&& value.title.length >= 1
|
|
96
|
+
&& value.title.length <= 1_024
|
|
97
|
+
&& typeof value.currentRevisionId === "string"
|
|
98
|
+
&& REVISION_ID.test(value.currentRevisionId)
|
|
99
|
+
&& (value.origin === undefined || (typeof value.origin === "string" && value.origin.length <= 256))
|
|
100
|
+
&& (value.dataResidency === undefined
|
|
101
|
+
|| value.dataResidency === "local"
|
|
102
|
+
|| value.dataResidency === "cn"
|
|
103
|
+
|| value.dataResidency === "global")
|
|
104
|
+
&& (value.capabilityLock === undefined || isArtifactLockRef(value.capabilityLock))
|
|
105
|
+
&& (value.templateLock === undefined || isArtifactLockRef(value.templateLock));
|
|
106
|
+
}
|
|
107
|
+
function isRevision(value) {
|
|
108
|
+
if (!plainRecord(value) || !exactKeys(value, [
|
|
109
|
+
"revisionId",
|
|
110
|
+
"artifactId",
|
|
111
|
+
"baseRevisionId",
|
|
112
|
+
"actor",
|
|
113
|
+
"contentRef",
|
|
114
|
+
"assetRefs",
|
|
115
|
+
"contentDigest",
|
|
116
|
+
"changedPaths",
|
|
117
|
+
"createdAt",
|
|
118
|
+
], ["parentRevisionId", "taskRunId"]))
|
|
119
|
+
return false;
|
|
120
|
+
return typeof value.revisionId === "string"
|
|
121
|
+
&& REVISION_ID.test(value.revisionId)
|
|
122
|
+
&& typeof value.artifactId === "string"
|
|
123
|
+
&& ARTIFACT_ID.test(value.artifactId)
|
|
124
|
+
&& typeof value.baseRevisionId === "string"
|
|
125
|
+
&& REVISION_ID.test(value.baseRevisionId)
|
|
126
|
+
&& (value.parentRevisionId === undefined
|
|
127
|
+
|| (typeof value.parentRevisionId === "string" && REVISION_ID.test(value.parentRevisionId)))
|
|
128
|
+
&& (value.actor === "user" || value.actor === "agent" || value.actor === "migration")
|
|
129
|
+
&& (value.taskRunId === undefined || (typeof value.taskRunId === "string" && value.taskRunId.length > 0))
|
|
130
|
+
&& typeof value.contentRef === "string"
|
|
131
|
+
&& SAFE_CONTENT_REF.test(value.contentRef)
|
|
132
|
+
&& Array.isArray(value.assetRefs)
|
|
133
|
+
&& value.assetRefs.length <= 10_000
|
|
134
|
+
&& value.assetRefs.every((entry) => typeof entry === "string" && entry.length > 0)
|
|
135
|
+
&& typeof value.contentDigest === "string"
|
|
136
|
+
&& /^[a-f0-9]{64}$/.test(value.contentDigest)
|
|
137
|
+
&& Array.isArray(value.changedPaths)
|
|
138
|
+
&& value.changedPaths.length <= 10_000
|
|
139
|
+
&& value.changedPaths.every((entry) => typeof entry === "string" && entry.length > 0)
|
|
140
|
+
&& typeof value.createdAt === "string"
|
|
141
|
+
&& Number.isFinite(Date.parse(value.createdAt));
|
|
142
|
+
}
|
|
143
|
+
function isContentInfo(value) {
|
|
144
|
+
if (!plainRecord(value) || !exactKeys(value, ["contentRef", "extension", "mediaType", "byteSize", "sha256"], []))
|
|
145
|
+
return false;
|
|
146
|
+
return typeof value.contentRef === "string"
|
|
147
|
+
&& SAFE_CONTENT_REF.test(value.contentRef)
|
|
148
|
+
&& typeof value.extension === "string"
|
|
149
|
+
&& /^\.[a-z0-9]{1,12}$/.test(value.extension)
|
|
150
|
+
&& value.contentRef === `content${value.extension}`
|
|
151
|
+
&& typeof value.mediaType === "string"
|
|
152
|
+
&& value.mediaType.length > 0
|
|
153
|
+
&& typeof value.byteSize === "number"
|
|
154
|
+
&& Number.isSafeInteger(value.byteSize)
|
|
155
|
+
&& value.byteSize >= 0
|
|
156
|
+
&& value.byteSize <= MAX_ARTIFACT_IMPORT_BYTES
|
|
157
|
+
&& typeof value.sha256 === "string"
|
|
158
|
+
&& /^[a-f0-9]{64}$/.test(value.sha256);
|
|
159
|
+
}
|
|
160
|
+
function checkedArtifactId(value) {
|
|
161
|
+
if (!ARTIFACT_ID.test(value)) {
|
|
162
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "artifactId is not a valid opaque Artifact id");
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
function checkedRevisionId(value) {
|
|
167
|
+
if (!REVISION_ID.test(value)) {
|
|
168
|
+
throw storeError("ARTIFACT_CORRUPT", "revision id is invalid");
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
function childDirectory(parent, name, notFoundMessage) {
|
|
173
|
+
verifyDirectory(parent);
|
|
174
|
+
const path = join(parent.path, name);
|
|
175
|
+
let info;
|
|
176
|
+
try {
|
|
177
|
+
info = lstatSync(path);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error?.code === "ENOENT")
|
|
181
|
+
throw storeError("ARTIFACT_NOT_FOUND", notFoundMessage);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (!info.isDirectory()
|
|
185
|
+
|| info.isSymbolicLink()
|
|
186
|
+
|| realpathSync.native(path) !== path)
|
|
187
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact directory is not a canonical private directory");
|
|
188
|
+
verifyDirectory(parent);
|
|
189
|
+
return { path, dev: info.dev, ino: info.ino };
|
|
190
|
+
}
|
|
191
|
+
function artifactDirectory(home, artifactId) {
|
|
192
|
+
return childDirectory(artifactRoot(home), checkedArtifactId(artifactId), `no artifact ${artifactId}`);
|
|
193
|
+
}
|
|
194
|
+
function revisionDirectory(artifact, revisionId) {
|
|
195
|
+
const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
|
|
196
|
+
return childDirectory(revisions, checkedRevisionId(revisionId), `no revision ${revisionId}`);
|
|
197
|
+
}
|
|
198
|
+
function inspectContent(revision, content, verifyDigest) {
|
|
199
|
+
verifyDirectory(revision);
|
|
200
|
+
const path = join(revision.path, content.contentRef);
|
|
201
|
+
const before = lstatSync(path);
|
|
202
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
203
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
|
|
204
|
+
}
|
|
205
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
206
|
+
try {
|
|
207
|
+
const opened = fstatSync(fd);
|
|
208
|
+
if (!opened.isFile()
|
|
209
|
+
|| opened.nlink !== 1
|
|
210
|
+
|| !sameOpenedFileIdentity(before, opened)
|
|
211
|
+
|| opened.size !== content.byteSize
|
|
212
|
+
|| (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
|
|
213
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
|
|
214
|
+
if (verifyDigest) {
|
|
215
|
+
const hash = createHash("sha256");
|
|
216
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
217
|
+
let position = 0;
|
|
218
|
+
for (;;) {
|
|
219
|
+
const read = readSync(fd, chunk, 0, chunk.length, position);
|
|
220
|
+
if (!read)
|
|
221
|
+
break;
|
|
222
|
+
hash.update(chunk.subarray(0, read));
|
|
223
|
+
position += read;
|
|
224
|
+
}
|
|
225
|
+
if (hash.digest("hex") !== content.sha256) {
|
|
226
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const after = fstatSync(fd);
|
|
230
|
+
const linked = lstatSync(path);
|
|
231
|
+
if (!sameOpenedFileIdentity(opened, after)
|
|
232
|
+
|| opened.size !== after.size
|
|
233
|
+
|| opened.mtimeMs !== after.mtimeMs
|
|
234
|
+
|| opened.ctimeMs !== after.ctimeMs
|
|
235
|
+
|| !sameOpenedFileIdentity(after, linked))
|
|
236
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was inspected");
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
closeSync(fd);
|
|
240
|
+
}
|
|
241
|
+
verifyDirectory(revision);
|
|
242
|
+
}
|
|
243
|
+
function readArtifactDetailsFromDirectory(directory, verifyDigest) {
|
|
244
|
+
verifyDirectory(directory);
|
|
245
|
+
const artifactValue = parseJsonFile(join(directory.path, "metadata.json"));
|
|
246
|
+
if (!isArtifactRecord(artifactValue) || artifactValue.artifactId !== basename(directory.path)) {
|
|
247
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
|
|
248
|
+
}
|
|
249
|
+
const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
|
|
250
|
+
const revisionDir = revisionDirectory(directory, currentRevisionId);
|
|
251
|
+
const revisionValue = parseJsonFile(join(revisionDir.path, "revision.json"));
|
|
252
|
+
const contentValue = parseJsonFile(join(revisionDir.path, "content.json"));
|
|
253
|
+
if (!isRevision(revisionValue)
|
|
254
|
+
|| revisionValue.revisionId !== currentRevisionId
|
|
255
|
+
|| revisionValue.artifactId !== artifactValue.artifactId)
|
|
256
|
+
throw storeError("ARTIFACT_CORRUPT", "current revision metadata does not match its Artifact");
|
|
257
|
+
if (!isContentInfo(contentValue)
|
|
258
|
+
|| contentValue.contentRef !== revisionValue.contentRef
|
|
259
|
+
|| contentValue.sha256 !== revisionValue.contentDigest)
|
|
260
|
+
throw storeError("ARTIFACT_CORRUPT", "current revision content receipt is inconsistent");
|
|
261
|
+
inspectContent(revisionDir, contentValue, verifyDigest);
|
|
262
|
+
verifyDirectory(directory);
|
|
263
|
+
return { artifact: artifactValue, currentRevision: revisionValue, content: contentValue };
|
|
264
|
+
}
|
|
265
|
+
function titleFor(sourcePath, extension, requested, kind) {
|
|
266
|
+
const raw = requested ?? basename(sourcePath, extension);
|
|
267
|
+
const cleaned = raw
|
|
268
|
+
.normalize("NFC")
|
|
269
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
270
|
+
.replace(/\s+/g, " ")
|
|
271
|
+
.trim();
|
|
272
|
+
const title = cleaned || `Untitled ${kind}`;
|
|
273
|
+
if (title.length > 1_024) {
|
|
274
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "artifact title must be at most 1024 characters");
|
|
275
|
+
}
|
|
276
|
+
return title;
|
|
277
|
+
}
|
|
278
|
+
async function readImportSource(sourcePath) {
|
|
279
|
+
let verified;
|
|
280
|
+
try {
|
|
281
|
+
verified = await openVerifiedRegularFileNoFollow(sourcePath, {
|
|
282
|
+
action: "import as an Artifact",
|
|
283
|
+
rejectHardLinks: true,
|
|
284
|
+
protectSensitive: true,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error?.code === "ENOENT") {
|
|
289
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source file no longer exists", error);
|
|
290
|
+
}
|
|
291
|
+
if (error?.code === "HARA_PROTECTED_CONTEXT_FILE") {
|
|
292
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "protected credential/configuration files cannot be imported", error);
|
|
293
|
+
}
|
|
294
|
+
if (error?.code === "HARA_HARD_LINKED_FILE") {
|
|
295
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "hard-linked source files cannot be imported safely", error);
|
|
296
|
+
}
|
|
297
|
+
if (error?.code === "HARA_NOT_REGULAR_FILE" || error?.code === "ELOOP") {
|
|
298
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source must be a regular file, not a link or device", error);
|
|
299
|
+
}
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const { handle, info } = verified;
|
|
304
|
+
if (info.size > MAX_ARTIFACT_IMPORT_BYTES) {
|
|
305
|
+
throw storeError("ARTIFACT_TOO_LARGE", `the selected file exceeds the ${MAX_ARTIFACT_IMPORT_BYTES / (1024 * 1024)} MiB import limit`);
|
|
306
|
+
}
|
|
307
|
+
if (info.size === 0) {
|
|
308
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "an empty file cannot be imported as an Artifact");
|
|
309
|
+
}
|
|
310
|
+
const bytes = Buffer.allocUnsafe(info.size);
|
|
311
|
+
let position = 0;
|
|
312
|
+
while (position < bytes.length) {
|
|
313
|
+
const { bytesRead } = await handle.read(bytes, position, bytes.length - position, position);
|
|
314
|
+
if (!bytesRead) {
|
|
315
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
|
|
316
|
+
}
|
|
317
|
+
position += bytesRead;
|
|
318
|
+
}
|
|
319
|
+
const after = await handle.stat();
|
|
320
|
+
verifyOpenedRegularFileSync(sourcePath, after, {
|
|
321
|
+
action: "import as an Artifact",
|
|
322
|
+
rejectHardLinks: true,
|
|
323
|
+
protectSensitive: true,
|
|
324
|
+
});
|
|
325
|
+
if (after.dev !== info.dev
|
|
326
|
+
|| after.ino !== info.ino
|
|
327
|
+
|| after.size !== info.size
|
|
328
|
+
|| after.mtimeMs !== info.mtimeMs
|
|
329
|
+
|| after.ctimeMs !== info.ctimeMs)
|
|
330
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
|
|
331
|
+
return bytes;
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
await verified.handle.close().catch(() => { });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function verifyClaimedFormat(extension, bytes) {
|
|
338
|
+
const startsWith = (signature) => bytes.length >= signature.length
|
|
339
|
+
&& signature.every((value, index) => bytes[index] === value);
|
|
340
|
+
if (ZIP_FORMATS.has(extension) && !startsWith([0x50, 0x4b, 0x03, 0x04])) {
|
|
341
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected Office file does not match its extension; save an unencrypted, macro-free copy and try again");
|
|
342
|
+
}
|
|
343
|
+
if (COMPOUND_FORMATS.has(extension)
|
|
344
|
+
&& !startsWith([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])) {
|
|
345
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected legacy Office file does not match its extension");
|
|
346
|
+
}
|
|
347
|
+
if (extension === ".rtf"
|
|
348
|
+
&& !bytes.subarray(0, Math.min(bytes.length, 32)).toString("utf8").replace(/^\uFEFF/, "").startsWith("{\\rtf")) {
|
|
349
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected RTF file does not match its extension");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function syncDirectory(path) {
|
|
353
|
+
let fd;
|
|
354
|
+
try {
|
|
355
|
+
fd = openSync(path, constants.O_RDONLY
|
|
356
|
+
| optionalPosixOpenFlag("O_NONBLOCK")
|
|
357
|
+
| optionalPosixOpenFlag("O_NOFOLLOW")
|
|
358
|
+
| optionalPosixOpenFlag("O_DIRECTORY"));
|
|
359
|
+
fsyncSync(fd);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
/* Directory fsync is not portable; every staged file is still fsync'd before activation. */
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
if (fd !== undefined)
|
|
366
|
+
closeSync(fd);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function activateStaging(root, staging, artifactId) {
|
|
370
|
+
verifyDirectory(root);
|
|
371
|
+
verifyDirectory(staging);
|
|
372
|
+
const destination = join(root.path, artifactId);
|
|
373
|
+
try {
|
|
374
|
+
lstatSync(destination);
|
|
375
|
+
throw storeError("ARTIFACT_CORRUPT", "an Artifact id collision occurred; retry the import");
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (error?.code !== "ENOENT")
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
renameSync(staging.path, destination);
|
|
382
|
+
const activated = lstatSync(destination);
|
|
383
|
+
if (!activated.isDirectory()
|
|
384
|
+
|| activated.isSymbolicLink()
|
|
385
|
+
|| activated.dev !== staging.dev
|
|
386
|
+
|| activated.ino !== staging.ino
|
|
387
|
+
|| realpathSync.native(destination) !== destination)
|
|
388
|
+
throw storeError("ARTIFACT_CORRUPT", "Artifact activation changed the staged directory identity");
|
|
389
|
+
verifyDirectory(root);
|
|
390
|
+
syncDirectory(root.path);
|
|
391
|
+
}
|
|
392
|
+
export async function importArtifact(home, input) {
|
|
393
|
+
if (typeof input.sourcePath !== "string" || !input.sourcePath || input.sourcePath.length > 4_096) {
|
|
394
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
|
|
395
|
+
}
|
|
396
|
+
if (!isAbsolute(input.sourcePath)) {
|
|
397
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
|
|
398
|
+
}
|
|
399
|
+
const sourcePath = resolve(input.sourcePath);
|
|
400
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
401
|
+
if (MACRO_FORMATS.has(extension)) {
|
|
402
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before importing");
|
|
403
|
+
}
|
|
404
|
+
const format = FORMATS.get(extension);
|
|
405
|
+
if (!format) {
|
|
406
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
|
|
407
|
+
}
|
|
408
|
+
if (input.kind !== undefined && input.kind !== format.kind) {
|
|
409
|
+
throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${input.kind}`);
|
|
410
|
+
}
|
|
411
|
+
if (input.kind !== undefined
|
|
412
|
+
&& input.kind !== "presentation"
|
|
413
|
+
&& input.kind !== "spreadsheet"
|
|
414
|
+
&& input.kind !== "document")
|
|
415
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "kind must be presentation, spreadsheet, or document");
|
|
416
|
+
if (input.title !== undefined && typeof input.title !== "string") {
|
|
417
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "title must be a string");
|
|
418
|
+
}
|
|
419
|
+
const bytes = await readImportSource(sourcePath);
|
|
420
|
+
verifyClaimedFormat(extension, bytes);
|
|
421
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
422
|
+
const artifactId = newOpaqueId("art");
|
|
423
|
+
const revisionId = newOpaqueId("rev");
|
|
424
|
+
const now = new Date().toISOString();
|
|
425
|
+
const contentRef = `content${extension}`;
|
|
426
|
+
const artifact = {
|
|
427
|
+
protocol: ARTIFACT_PROTOCOL_VERSION,
|
|
428
|
+
artifactId,
|
|
429
|
+
kind: format.kind,
|
|
430
|
+
title: titleFor(sourcePath, extension, input.title, format.kind),
|
|
431
|
+
currentRevisionId: revisionId,
|
|
432
|
+
origin: "local-import",
|
|
433
|
+
dataResidency: "local",
|
|
434
|
+
};
|
|
435
|
+
const revision = {
|
|
436
|
+
revisionId,
|
|
437
|
+
artifactId,
|
|
438
|
+
baseRevisionId: revisionId,
|
|
439
|
+
actor: "user",
|
|
440
|
+
contentRef,
|
|
441
|
+
assetRefs: [],
|
|
442
|
+
contentDigest: sha256,
|
|
443
|
+
changedPaths: ["content"],
|
|
444
|
+
createdAt: now,
|
|
445
|
+
};
|
|
446
|
+
const content = {
|
|
447
|
+
contentRef,
|
|
448
|
+
extension,
|
|
449
|
+
mediaType: format.mediaType,
|
|
450
|
+
byteSize: bytes.byteLength,
|
|
451
|
+
sha256,
|
|
452
|
+
};
|
|
453
|
+
const root = artifactRoot(home);
|
|
454
|
+
const stagingName = `.staging-${artifactId}-${randomUUID().replaceAll("-", "")}`;
|
|
455
|
+
const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName]);
|
|
456
|
+
ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName, "revisions", revisionId]);
|
|
457
|
+
writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], contentRef), bytes);
|
|
458
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
|
|
459
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
|
|
460
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName], "metadata.json"), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
461
|
+
activateStaging(root, staging, artifactId);
|
|
462
|
+
return getArtifact(home, artifactId, false);
|
|
463
|
+
}
|
|
464
|
+
export function getArtifact(home, artifactId, verifyDigest = true) {
|
|
465
|
+
return readArtifactDetailsFromDirectory(artifactDirectory(home, artifactId), verifyDigest);
|
|
466
|
+
}
|
|
467
|
+
export function listArtifacts(home) {
|
|
468
|
+
const root = artifactRoot(home);
|
|
469
|
+
verifyDirectory(root);
|
|
470
|
+
const entries = readdirSync(root.path, { withFileTypes: true });
|
|
471
|
+
const candidates = entries.filter((entry) => !entry.name.startsWith("."));
|
|
472
|
+
const artifacts = [];
|
|
473
|
+
let invalid = 0;
|
|
474
|
+
for (const entry of candidates.slice(0, MAX_ARTIFACTS)) {
|
|
475
|
+
if (!entry.isDirectory() || !ARTIFACT_ID.test(entry.name)) {
|
|
476
|
+
invalid += 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const directory = childDirectory(root, entry.name, `no artifact ${entry.name}`);
|
|
481
|
+
const details = readArtifactDetailsFromDirectory(directory, false);
|
|
482
|
+
artifacts.push({
|
|
483
|
+
artifactId: details.artifact.artifactId,
|
|
484
|
+
kind: details.artifact.kind,
|
|
485
|
+
title: details.artifact.title,
|
|
486
|
+
currentRevisionId: details.artifact.currentRevisionId,
|
|
487
|
+
updatedAt: details.currentRevision.createdAt,
|
|
488
|
+
extension: details.content.extension,
|
|
489
|
+
mediaType: details.content.mediaType,
|
|
490
|
+
byteSize: details.content.byteSize,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
invalid += 1;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
verifyDirectory(root);
|
|
498
|
+
artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.artifactId.localeCompare(b.artifactId));
|
|
499
|
+
return {
|
|
500
|
+
artifacts,
|
|
501
|
+
invalid,
|
|
502
|
+
truncated: candidates.length > MAX_ARTIFACTS,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
export function listArtifactRevisions(home, artifactId) {
|
|
506
|
+
const artifact = artifactDirectory(home, artifactId);
|
|
507
|
+
const metadata = parseJsonFile(join(artifact.path, "metadata.json"));
|
|
508
|
+
if (!isArtifactRecord(metadata) || metadata.artifactId !== artifactId) {
|
|
509
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
|
|
510
|
+
}
|
|
511
|
+
const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
|
|
512
|
+
const entries = readdirSync(revisions.path, { withFileTypes: true });
|
|
513
|
+
if (entries.length > MAX_REVISIONS) {
|
|
514
|
+
throw storeError("ARTIFACT_CORRUPT", `artifact has more than ${MAX_REVISIONS} revisions`);
|
|
515
|
+
}
|
|
516
|
+
const out = [];
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
if (!entry.isDirectory() || !REVISION_ID.test(entry.name)) {
|
|
519
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact contains an invalid revision entry");
|
|
520
|
+
}
|
|
521
|
+
const directory = childDirectory(revisions, entry.name, `no revision ${entry.name}`);
|
|
522
|
+
const value = parseJsonFile(join(directory.path, "revision.json"));
|
|
523
|
+
if (!isRevision(value) || value.revisionId !== entry.name || value.artifactId !== artifactId) {
|
|
524
|
+
throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its directory");
|
|
525
|
+
}
|
|
526
|
+
out.push(value);
|
|
527
|
+
}
|
|
528
|
+
verifyDirectory(revisions);
|
|
529
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.revisionId.localeCompare(b.revisionId));
|
|
530
|
+
}
|
|
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
9
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
10
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
11
|
-
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts"]);
|
|
11
|
+
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
|
|
12
12
|
const tightenedHomes = new Set();
|
|
13
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
14
14
|
function isMissing(error) {
|
|
@@ -300,6 +300,99 @@ function syncPrivateDirectory(path) {
|
|
|
300
300
|
/* Directory fsync is not portable; the staged file itself is always fsynced. */
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Crash-safe immutable binary creation below a bound private-state directory.
|
|
305
|
+
*
|
|
306
|
+
* Artifact revisions use this instead of the text replacement writer: revision content is immutable,
|
|
307
|
+
* may be a binary Office file, and must never replace an entry that appeared concurrently. The staged
|
|
308
|
+
* inode is fsync'd, hard-linked into place with create-if-absent semantics, and checked again after the
|
|
309
|
+
* temporary name is removed.
|
|
310
|
+
*/
|
|
311
|
+
export function writePrivateStateBytesOnceSync(binding, bytes) {
|
|
312
|
+
const { directory, path } = binding;
|
|
313
|
+
verifyPrivateDirectory(directory);
|
|
314
|
+
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
315
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
lstatSync(path);
|
|
319
|
+
throw new Error(`private Hara state file already exists: '${path}'`);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error?.code !== "ENOENT")
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
326
|
+
let fd;
|
|
327
|
+
let staged;
|
|
328
|
+
try {
|
|
329
|
+
fd = openSync(temp, "wx", 0o600);
|
|
330
|
+
writeFileSync(fd, bytes);
|
|
331
|
+
try {
|
|
332
|
+
fchmodSync(fd, 0o600);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (process.platform !== "win32")
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
fsyncSync(fd);
|
|
339
|
+
const stagedInfo = fstatSync(fd);
|
|
340
|
+
if (!stagedInfo.isFile() || stagedInfo.nlink !== 1) {
|
|
341
|
+
throw new Error("unsafe private Hara binary staging inode");
|
|
342
|
+
}
|
|
343
|
+
staged = {
|
|
344
|
+
dev: stagedInfo.dev,
|
|
345
|
+
ino: stagedInfo.ino,
|
|
346
|
+
mode: stagedInfo.mode & 0o777,
|
|
347
|
+
nlink: stagedInfo.nlink,
|
|
348
|
+
};
|
|
349
|
+
closeSync(fd);
|
|
350
|
+
fd = undefined;
|
|
351
|
+
verifyPrivateDirectory(directory);
|
|
352
|
+
try {
|
|
353
|
+
linkSync(temp, path);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (error?.code === "EEXIST") {
|
|
357
|
+
throw new Error(`private Hara state file changed before create: '${path}'`);
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
|
|
362
|
+
if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
|
|
363
|
+
throw new Error(`private Hara binary staging identity changed during commit: '${path}'`
|
|
364
|
+
+ `; expected=${JSON.stringify(linkedStaged)}`
|
|
365
|
+
+ `; staging=${privateFileIdentitySummary(temp)}`
|
|
366
|
+
+ `; target=${privateFileIdentitySummary(path)}`);
|
|
367
|
+
}
|
|
368
|
+
unlinkSync(temp);
|
|
369
|
+
const committed = lstatSync(path);
|
|
370
|
+
if (!committed.isFile()
|
|
371
|
+
|| committed.isSymbolicLink()
|
|
372
|
+
|| !sameOpenedFileIdentity(committed, staged)
|
|
373
|
+
|| committed.nlink !== 1
|
|
374
|
+
|| (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
|
|
375
|
+
throw new Error(`private Hara state file changed during commit: '${path}'`);
|
|
376
|
+
verifyPrivateDirectory(directory);
|
|
377
|
+
syncPrivateDirectory(directory.path);
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
if (fd !== undefined)
|
|
381
|
+
try {
|
|
382
|
+
closeSync(fd);
|
|
383
|
+
}
|
|
384
|
+
catch { /* preserve original error */ }
|
|
385
|
+
if (staged) {
|
|
386
|
+
try {
|
|
387
|
+
if (samePrivateFile(temp, staged))
|
|
388
|
+
unlinkSync(temp);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
/* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
303
396
|
/**
|
|
304
397
|
* Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
|
|
305
398
|
* are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
|
package/dist/serve/protocol.js
CHANGED
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
// → {id,name,schedule}
|
|
29
29
|
// automation.toggle {id,enabled} → {id,enabled}
|
|
30
30
|
// automation.delete {id} → {id,deleted}
|
|
31
|
+
// artifact.import {sourcePath,title?,kind?} → {artifact,currentRevision,content}
|
|
32
|
+
// artifact.list {} → {artifacts,invalid,truncated}
|
|
33
|
+
// artifact.get {artifactId} → {artifact,currentRevision,content}
|
|
34
|
+
// artifact.revisions {artifactId} → {artifactId,revisions}
|
|
31
35
|
// session.rename {sessionId,title} → {sessionId,title}
|
|
32
36
|
// session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
|
|
33
37
|
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
package/dist/serve/server.js
CHANGED
|
@@ -37,12 +37,22 @@ import { readModelContextFileSync } from "../fs-read.js";
|
|
|
37
37
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
38
38
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
39
39
|
import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
|
|
40
|
+
import { ArtifactStoreError, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, } from "../artifacts/store.js";
|
|
40
41
|
import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
|
|
41
42
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
42
43
|
const COMPACT_TIMEOUT_MS = 60_000;
|
|
43
44
|
const SHUTDOWN_GRACE_MS = 2_000;
|
|
44
45
|
const SOCKET_CLOSE_GRACE_MS = 250;
|
|
45
46
|
const DISCOVERY_LOCK_WAIT_MS = 2_000;
|
|
47
|
+
const artifactRpcError = (id, error, action) => {
|
|
48
|
+
if (error instanceof ArtifactStoreError) {
|
|
49
|
+
const code = error.code === "ARTIFACT_CORRUPT" ? ERR.INTERNAL : ERR.PARAMS;
|
|
50
|
+
return rpcError(id, code, error.message);
|
|
51
|
+
}
|
|
52
|
+
return rpcError(id, ERR.INTERNAL, action === "import"
|
|
53
|
+
? "Artifact import failed safely; the source file was not modified"
|
|
54
|
+
: `Artifact ${action} failed safely; local Artifact data was not changed`);
|
|
55
|
+
};
|
|
46
56
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
57
|
const isPidAlive = (pid) => {
|
|
48
58
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
@@ -349,6 +359,7 @@ export async function startServe(opts, deps) {
|
|
|
349
359
|
};
|
|
350
360
|
// Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
|
|
351
361
|
const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
|
|
362
|
+
const artifactHome = deps.artifactHome ?? homedir();
|
|
352
363
|
const discoveryPath = join(discoveryDir, "serve.json");
|
|
353
364
|
const discovery = { host: opts.host, port, token, pid: process.pid, version: deps.version, instanceId };
|
|
354
365
|
if (!deps.quietDiscovery) {
|
|
@@ -731,6 +742,7 @@ export async function startServe(opts, deps) {
|
|
|
731
742
|
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
732
743
|
"settings.providers.list", "settings.providers.test", "settings.providers.save",
|
|
733
744
|
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
745
|
+
"artifact.import", "artifact.list", "artifact.get", "artifact.revisions",
|
|
734
746
|
"tasks.list", "approvals.list", "approvals.resolve",
|
|
735
747
|
];
|
|
736
748
|
const runtime = runtimeInfo();
|
|
@@ -1126,6 +1138,61 @@ export async function startServe(opts, deps) {
|
|
|
1126
1138
|
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
1127
1139
|
return reply(rpcResult(id, { id: p.id, deleted: true }));
|
|
1128
1140
|
}
|
|
1141
|
+
case "artifact.import": {
|
|
1142
|
+
if (typeof p.sourcePath !== "string" || !p.sourcePath) {
|
|
1143
|
+
return reply(rpcError(id, ERR.PARAMS, "sourcePath required"));
|
|
1144
|
+
}
|
|
1145
|
+
if (p.kind !== undefined
|
|
1146
|
+
&& p.kind !== "presentation"
|
|
1147
|
+
&& p.kind !== "spreadsheet"
|
|
1148
|
+
&& p.kind !== "document")
|
|
1149
|
+
return reply(rpcError(id, ERR.PARAMS, "kind must be presentation, spreadsheet, or document"));
|
|
1150
|
+
if (p.title !== undefined && typeof p.title !== "string") {
|
|
1151
|
+
return reply(rpcError(id, ERR.PARAMS, "title must be a string"));
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
const details = await importArtifact(artifactHome, {
|
|
1155
|
+
sourcePath: p.sourcePath,
|
|
1156
|
+
...(p.title !== undefined ? { title: p.title } : {}),
|
|
1157
|
+
...(p.kind !== undefined ? { kind: p.kind } : {}),
|
|
1158
|
+
});
|
|
1159
|
+
return reply(rpcResult(id, details));
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
return reply(artifactRpcError(id, error, "import"));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
case "artifact.list": {
|
|
1166
|
+
try {
|
|
1167
|
+
return reply(rpcResult(id, listArtifacts(artifactHome)));
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
return reply(artifactRpcError(id, error, "list"));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
case "artifact.get": {
|
|
1174
|
+
if (typeof p.artifactId !== "string") {
|
|
1175
|
+
return reply(rpcError(id, ERR.PARAMS, "artifactId required"));
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
return reply(rpcResult(id, getArtifact(artifactHome, p.artifactId)));
|
|
1179
|
+
}
|
|
1180
|
+
catch (error) {
|
|
1181
|
+
return reply(artifactRpcError(id, error, "open"));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
case "artifact.revisions": {
|
|
1185
|
+
if (typeof p.artifactId !== "string") {
|
|
1186
|
+
return reply(rpcError(id, ERR.PARAMS, "artifactId required"));
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
const revisions = listArtifactRevisions(artifactHome, p.artifactId);
|
|
1190
|
+
return reply(rpcResult(id, { artifactId: p.artifactId, revisions }));
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
return reply(artifactRpcError(id, error, "list revisions"));
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1129
1196
|
case "skills.list": {
|
|
1130
1197
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
1131
1198
|
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|