@xfey/tutti 0.1.107 → 0.1.109
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/README.md +3 -0
- package/dist/chat-assistant/diagnostics.d.ts +17 -0
- package/dist/chat-assistant/diagnostics.js +27 -0
- package/dist/chat-assistant/index.d.ts +4 -0
- package/dist/chat-assistant/index.js +30 -7
- package/dist/collaboration-ingestion/external-source.js +10 -1
- package/dist/control-plane/task-source-context-types.d.ts +2 -1
- package/dist/control-plane/task-source-context.js +7 -0
- package/dist/providers/invocation-progress.d.ts +10 -0
- package/dist/providers/invocation-progress.js +9 -0
- package/dist/providers/openai/app-server/read-only-procedure.d.ts +2 -0
- package/dist/providers/openai/app-server/read-only-procedure.js +34 -2
- package/dist/providers/openai/app-server/skill-discovery.d.ts +10 -0
- package/dist/providers/openai/app-server/skill-discovery.js +29 -0
- package/dist/providers/openai/app-server/skills.d.ts +8 -17
- package/dist/providers/openai/app-server/skills.js +16 -31
- package/dist/providers/openai/app-server/workspace-write-run.js +24 -26
- package/dist/providers/openai/chat-assistant.js +12 -2
- package/dist/reference-access/cache.d.ts +50 -0
- package/dist/reference-access/cache.js +268 -0
- package/dist/reference-access/read-evidence.d.ts +6 -0
- package/dist/reference-access/read-evidence.js +15 -0
- package/dist/reference-access/service.d.ts +26 -0
- package/dist/reference-access/service.js +42 -0
- package/dist/server-shell/cli/external-reference-runtime.d.ts +12 -0
- package/dist/server-shell/cli/external-reference-runtime.js +75 -0
- package/dist/server-shell/cli/host-server-runtime.js +19 -1
- package/dist/server-shell/cli/relay-registration.js +1 -0
- package/dist/server-shell/http/create-server.d.ts +2 -0
- package/dist/server-shell/http/create-server.js +5 -4
- package/dist/server-shell/http/routes/agent-context-auth.d.ts +2 -1
- package/dist/server-shell/http/routes/agent-context-auth.js +3 -1
- package/dist/server-shell/http/routes/agent-context-external-reference-routes.d.ts +4 -0
- package/dist/server-shell/http/routes/agent-context-external-reference-routes.js +108 -0
- package/dist/server-shell/http/routes/agent-context-shared.d.ts +2 -0
- package/dist/server-shell/http/routes/agent-context.js +3 -1
- package/dist/server-shell/http/static-web.js +10 -6
- package/dist/workspace-ops/project-workspace.js +6 -6
- package/dist/workspace-ops/reference-cache-policy.d.ts +6 -0
- package/dist/workspace-ops/reference-cache-policy.js +79 -0
- package/migrations/0021_external_reference_reads.sql +11 -0
- package/node_modules/@tutti/relay-client/dist/external-references.d.ts +11 -0
- package/node_modules/@tutti/relay-client/dist/external-references.js +63 -0
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +1 -0
- package/node_modules/@tutti/relay-client/dist/host-control.js +1 -0
- package/node_modules/@tutti/relay-client/dist/index.d.ts +1 -0
- package/node_modules/@tutti/relay-client/dist/index.js +1 -0
- package/node_modules/@tutti/shared/dist/schemas/api/clarifications.d.ts +6 -0
- package/node_modules/@tutti/shared/dist/schemas/api/external-references.d.ts +23 -0
- package/node_modules/@tutti/shared/dist/schemas/api/external-references.js +21 -0
- package/node_modules/@tutti/shared/dist/schemas/api/feishu-documents.d.ts +6 -1
- package/node_modules/@tutti/shared/dist/schemas/api/feishu-documents.js +16 -3
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +3 -2
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +2 -1
- package/node_modules/@tutti/shared/dist/schemas/api/messages.d.ts +24 -0
- package/node_modules/@tutti/shared/dist/schemas/api/messages.js +2 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +7 -1
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +17 -3
- package/node_modules/@tutti/shared/dist/schemas/api/viewer-reference.d.ts +6 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/external-references.d.ts +109 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/external-references.js +70 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-documents.d.ts +11 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-documents.js +5 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +1 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.js +1 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +36 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +3 -0
- package/package.json +1 -1
- package/prompts/skills/read-references/SKILL.md +32 -3
- package/web/assets/{homepage-motion-scene-CFs5rMWq.js → homepage-motion-scene-B6yPJnY8.js} +1 -1
- package/web/assets/{index-BmiLZmaV.js → index-aQnJ5JoI.js} +3 -3
- package/web/index.html +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { discoverCodexAppServerSkills } from "./skill-discovery.js";
|
|
5
6
|
import { buildCodexAppServerProcessPlan } from "../codex-app-server.js";
|
|
6
7
|
import { DEFAULT_OPENAI_MODEL } from "../model-config.js";
|
|
7
8
|
import { CodexAppServerJsonRpcClient, CodexAppServerProtocolError, } from "./json-rpc.js";
|
|
@@ -10,7 +11,7 @@ import { CodexAppServerAdmissionError, } from "./invocation-coordinator.js";
|
|
|
10
11
|
import { createCodexAppServerRuntimeObserver, } from "./runtime-telemetry.js";
|
|
11
12
|
import { createReadOnlySandboxPolicy, createWorkspaceWriteSandboxPolicy, } from "./sandbox-policy.js";
|
|
12
13
|
import { buildCodexAppServerProviderConfigOverrides, resolveCodexAppServerModelProvider, } from "./provider-config.js";
|
|
13
|
-
import { buildCodexAppServerTurnInput, resolveCodexAppServerContextRuntimeHint,
|
|
14
|
+
import { buildCodexAppServerTurnInput, resolveCodexAppServerContextRuntimeHint, resolveCodexAppServerSkillInputs, CodexAppServerSkillSelectionError, } from "./skills.js";
|
|
14
15
|
import { compactText, containsKnownSecret, createCodexSpawnEnv, extractAgentText, extractServerRequestMethod, findJsonObjectCandidates, isApiKeyAccount, readThreadId, readTurnError, readTurnStatus, redactWithKnownValues, } from "./runtime-helpers.js";
|
|
15
16
|
import { extractCodexAppServerTokenUsage } from "../token-usage.js";
|
|
16
17
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
@@ -170,23 +171,24 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
let skillInputs;
|
|
173
|
-
let skillsListWarmup;
|
|
174
174
|
let contextRuntimeHint;
|
|
175
175
|
try {
|
|
176
176
|
skillInputs = resolveCodexAppServerSkillInputs({
|
|
177
|
-
...(options.skillSelectionName === undefined
|
|
177
|
+
...(options.skillSelectionName === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { selectionName: options.skillSelectionName }),
|
|
178
180
|
...(options.userSkillsRoot === undefined ? {} : { userSkillsRoot: options.userSkillsRoot }),
|
|
179
181
|
...(options.builtInSkillsRoot === undefined
|
|
180
182
|
? {}
|
|
181
183
|
: { builtInSkillsRoot: options.builtInSkillsRoot }),
|
|
182
184
|
});
|
|
183
|
-
skillsListWarmup = resolveCodexAppServerSkillsListWarmup({
|
|
184
|
-
cwd: options.workspaceRoot,
|
|
185
|
-
...(options.skillSelectionName === undefined ? {} : { selectionName: options.skillSelectionName }),
|
|
186
|
-
...(options.userSkillsRoot === undefined ? {} : { userSkillsRoot: options.userSkillsRoot }),
|
|
187
|
-
});
|
|
188
185
|
contextRuntimeHint = resolveCodexAppServerContextRuntimeHint({
|
|
189
|
-
...(options.
|
|
186
|
+
...(options.approvalContext?.activityRef === undefined
|
|
187
|
+
? {}
|
|
188
|
+
: { activityRef: options.approvalContext.activityRef }),
|
|
189
|
+
...(options.skillSelectionName === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { selectionName: options.skillSelectionName }),
|
|
190
192
|
...(options.agentContext === undefined ? {} : { agentContext: options.agentContext }),
|
|
191
193
|
});
|
|
192
194
|
}
|
|
@@ -258,16 +260,9 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
|
|
|
258
260
|
options.userSkillsRoot ?? "",
|
|
259
261
|
options.builtInSkillsRoot ?? "",
|
|
260
262
|
...(contextRuntimeHint?.sensitiveValues ?? []),
|
|
261
|
-
...skillInputs.
|
|
262
|
-
...(skillsListWarmup?.perCwdExtraUserRoots.flatMap((entry) => [
|
|
263
|
-
entry.cwd,
|
|
264
|
-
...entry.extraUserRoots,
|
|
265
|
-
]) ?? []),
|
|
266
|
-
];
|
|
267
|
-
const strictLeakCheckValues = [
|
|
268
|
-
options.apiKey,
|
|
269
|
-
...(contextRuntimeHint?.sensitiveValues ?? []),
|
|
263
|
+
...skillInputs.flatMap((skill) => [skill.path, dirname(skill.path)]),
|
|
270
264
|
];
|
|
265
|
+
const strictLeakCheckValues = [options.apiKey, ...(contextRuntimeHint?.sensitiveValues ?? [])];
|
|
271
266
|
let primaryError;
|
|
272
267
|
let runResult;
|
|
273
268
|
let lifecycleError;
|
|
@@ -291,9 +286,12 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
|
|
|
291
286
|
if (!isApiKeyAccount(loginResponse)) {
|
|
292
287
|
throw new CodexAppServerWorkspaceWriteRunError("auth_failed", "Codex app-server did not accept API key auth.");
|
|
293
288
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
289
|
+
await discoverCodexAppServerSkills({
|
|
290
|
+
client,
|
|
291
|
+
cwd: options.workspaceRoot,
|
|
292
|
+
skills: skillInputs,
|
|
293
|
+
requestTimeoutMs,
|
|
294
|
+
});
|
|
297
295
|
let threadId;
|
|
298
296
|
if (options.session?.threadId === undefined) {
|
|
299
297
|
const threadStartResponse = await client.request("thread/start", {
|
|
@@ -336,9 +334,7 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
|
|
|
336
334
|
input: buildCodexAppServerTurnInput({
|
|
337
335
|
prompt: options.prompt,
|
|
338
336
|
skills: skillInputs,
|
|
339
|
-
...(contextRuntimeHint === undefined
|
|
340
|
-
? {}
|
|
341
|
-
: { runtimeHints: [contextRuntimeHint.text] }),
|
|
337
|
+
...(contextRuntimeHint === undefined ? {} : { runtimeHints: [contextRuntimeHint.text] }),
|
|
342
338
|
}),
|
|
343
339
|
cwd: options.workspaceRoot,
|
|
344
340
|
approvalPolicy,
|
|
@@ -480,7 +476,9 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
|
|
|
480
476
|
}
|
|
481
477
|
else {
|
|
482
478
|
const message = error instanceof Error ? error.message : String(error);
|
|
483
|
-
primaryError = new CodexAppServerWorkspaceWriteRunError(
|
|
479
|
+
primaryError = new CodexAppServerWorkspaceWriteRunError(error instanceof CodexAppServerSkillSelectionError
|
|
480
|
+
? "skill_selection_failed"
|
|
481
|
+
: "app_server_protocol_error", redactWithKnownValues(message, sensitiveValues), { turn_start_state: turnStartState });
|
|
484
482
|
}
|
|
485
483
|
}
|
|
486
484
|
finally {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { CHAT_ASSISTANT_OUTPUT_SCHEMA, isChatAssistantOutput, } from "../../chat-assistant/index.js";
|
|
2
2
|
import { readOpenAiApiKeyCredential } from "./credential-store.js";
|
|
3
|
-
import { resolveOpenAiProviderConfig
|
|
4
|
-
import { runCodexAppServerReadOnlyProcedure } from "./app-server/read-only-procedure.js";
|
|
3
|
+
import { resolveOpenAiProviderConfig } from "./provider-config.js";
|
|
4
|
+
import { CodexAppServerReadOnlyProcedureError, runCodexAppServerReadOnlyProcedure, } from "./app-server/read-only-procedure.js";
|
|
5
|
+
import { reportProviderInvocationProgress } from "../invocation-progress.js";
|
|
5
6
|
import { getProjectUserSkillsRoot } from "../../skills/index.js";
|
|
6
7
|
export function createOpenAiReadOnlyChatAssistantModel(options) {
|
|
7
8
|
return {
|
|
8
9
|
async answer(input) {
|
|
9
10
|
const result = await runCodexAppServerReadOnlyProcedure({
|
|
11
|
+
...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),
|
|
10
12
|
apiKey: options.apiKey,
|
|
11
13
|
workspaceRoot: options.workspaceRoot,
|
|
12
14
|
prompt: input.prompt,
|
|
@@ -37,6 +39,14 @@ export function createOpenAiReadOnlyChatAssistantModel(options) {
|
|
|
37
39
|
...(options.onLifecycleIncident === undefined
|
|
38
40
|
? {}
|
|
39
41
|
: { onLifecycleIncident: options.onLifecycleIncident }),
|
|
42
|
+
}).catch((error) => {
|
|
43
|
+
reportProviderInvocationProgress(input.onProgress, {
|
|
44
|
+
stage: "provider_failed",
|
|
45
|
+
reason_code: error instanceof CodexAppServerReadOnlyProcedureError
|
|
46
|
+
? error.code
|
|
47
|
+
: "provider_unavailable",
|
|
48
|
+
});
|
|
49
|
+
throw error;
|
|
40
50
|
});
|
|
41
51
|
if (options.recordUsage !== undefined && result.usage !== undefined) {
|
|
42
52
|
options.recordUsage({
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare class ReferenceCacheError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string);
|
|
4
|
+
}
|
|
5
|
+
export type ReferenceCopy = {
|
|
6
|
+
source_ref: string;
|
|
7
|
+
revision: string;
|
|
8
|
+
sha256: string;
|
|
9
|
+
path: string;
|
|
10
|
+
size_bytes: number;
|
|
11
|
+
media_type: string;
|
|
12
|
+
checked_at: string;
|
|
13
|
+
};
|
|
14
|
+
export type ReferenceFetchResult = {
|
|
15
|
+
revision: string;
|
|
16
|
+
media_type: string;
|
|
17
|
+
content: Uint8Array;
|
|
18
|
+
};
|
|
19
|
+
export declare class ExternalReferenceCache {
|
|
20
|
+
private readonly options;
|
|
21
|
+
private readonly workspace;
|
|
22
|
+
private readonly ledgerFile;
|
|
23
|
+
private readonly ledger;
|
|
24
|
+
private tail;
|
|
25
|
+
constructor(options: {
|
|
26
|
+
workspaceRoot: string;
|
|
27
|
+
ledgerRoot: string;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
maxTotalBytes?: number;
|
|
30
|
+
retentionMs?: number;
|
|
31
|
+
});
|
|
32
|
+
private now;
|
|
33
|
+
private validateLedger;
|
|
34
|
+
private save;
|
|
35
|
+
private bytes;
|
|
36
|
+
/** A pin belongs to a server-issued invocation, never to model-supplied identity. */
|
|
37
|
+
acquire(input: {
|
|
38
|
+
sourceRef: string;
|
|
39
|
+
invocationRef: string;
|
|
40
|
+
expiresAt: number;
|
|
41
|
+
fetch: () => Promise<ReferenceFetchResult>;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
copy: ReferenceCopy;
|
|
44
|
+
cache_hit: boolean;
|
|
45
|
+
content: Buffer;
|
|
46
|
+
}>;
|
|
47
|
+
private projection;
|
|
48
|
+
private prune;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { assertReferenceCacheIsolation, initializeReferenceCacheDirectory, REFERENCE_CACHE_PATH, } from "../workspace-ops/reference-cache-policy.js";
|
|
5
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const SAFE_REF = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
7
|
+
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
8
|
+
const MAX_PIN_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export class ReferenceCacheError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super(code);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "ReferenceCacheError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function fail() {
|
|
18
|
+
throw new ReferenceCacheError("reference_cache_invalid");
|
|
19
|
+
}
|
|
20
|
+
function hash(bytes) {
|
|
21
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
function regular(path) {
|
|
24
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
25
|
+
if (stat === undefined)
|
|
26
|
+
return false;
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
28
|
+
fail();
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function ensureDirectories(path) {
|
|
32
|
+
const absolute = resolve(path);
|
|
33
|
+
const parent = dirname(absolute);
|
|
34
|
+
if (parent !== absolute)
|
|
35
|
+
ensureDirectories(parent);
|
|
36
|
+
const stat = lstatSync(absolute, { throwIfNoEntry: false });
|
|
37
|
+
if (stat !== undefined) {
|
|
38
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
39
|
+
fail();
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
mkdirSync(absolute, { mode: 0o700 });
|
|
43
|
+
}
|
|
44
|
+
function relativeCopyPath(source, digest) {
|
|
45
|
+
return `${REFERENCE_CACHE_PATH}/${source}/${digest}.bin`;
|
|
46
|
+
}
|
|
47
|
+
export class ExternalReferenceCache {
|
|
48
|
+
options;
|
|
49
|
+
workspace;
|
|
50
|
+
ledgerFile;
|
|
51
|
+
ledger;
|
|
52
|
+
tail = Promise.resolve();
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.options = options;
|
|
55
|
+
this.workspace = realpathSync(options.workspaceRoot);
|
|
56
|
+
if ((options.maxTotalBytes !== undefined &&
|
|
57
|
+
(!Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 1)) ||
|
|
58
|
+
(options.retentionMs !== undefined &&
|
|
59
|
+
(!Number.isSafeInteger(options.retentionMs) || options.retentionMs < 0)))
|
|
60
|
+
fail();
|
|
61
|
+
const ledgerRoot = resolve(options.ledgerRoot);
|
|
62
|
+
if (ledgerRoot === this.workspace || ledgerRoot.startsWith(`${this.workspace}/`))
|
|
63
|
+
fail();
|
|
64
|
+
ensureDirectories(ledgerRoot);
|
|
65
|
+
this.ledgerFile = join(ledgerRoot, "cache.json");
|
|
66
|
+
if (regular(this.ledgerFile)) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(this.ledgerFile, "utf8"));
|
|
69
|
+
this.ledger = this.validateLedger(parsed);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
fail();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.ledger = { version: 1, workspace: this.workspace, entries: {}, pins: {} };
|
|
77
|
+
}
|
|
78
|
+
initializeReferenceCacheDirectory(this.workspace);
|
|
79
|
+
this.save();
|
|
80
|
+
}
|
|
81
|
+
now() {
|
|
82
|
+
return (this.options.now ?? Date.now)();
|
|
83
|
+
}
|
|
84
|
+
validateLedger(value) {
|
|
85
|
+
if (value === null || typeof value !== "object")
|
|
86
|
+
fail();
|
|
87
|
+
const candidate = value;
|
|
88
|
+
if (candidate.version !== 1 ||
|
|
89
|
+
candidate.workspace !== this.workspace ||
|
|
90
|
+
!candidate.entries ||
|
|
91
|
+
!candidate.pins ||
|
|
92
|
+
typeof candidate.entries !== "object" ||
|
|
93
|
+
typeof candidate.pins !== "object" ||
|
|
94
|
+
Array.isArray(candidate.entries) ||
|
|
95
|
+
Array.isArray(candidate.pins))
|
|
96
|
+
fail();
|
|
97
|
+
for (const [key, entry] of Object.entries(candidate.entries)) {
|
|
98
|
+
if (!entry ||
|
|
99
|
+
!SAFE_REF.test(entry.source_ref) ||
|
|
100
|
+
!DIGEST.test(entry.sha256) ||
|
|
101
|
+
key !== `${entry.source_ref}:${entry.sha256}` ||
|
|
102
|
+
entry.path !== relativeCopyPath(entry.source_ref, entry.sha256) ||
|
|
103
|
+
typeof entry.revision !== "string" ||
|
|
104
|
+
entry.revision.length > 256 ||
|
|
105
|
+
typeof entry.media_type !== "string" ||
|
|
106
|
+
entry.media_type.length > 128 ||
|
|
107
|
+
!Number.isSafeInteger(entry.size_bytes) ||
|
|
108
|
+
entry.size_bytes < 0 ||
|
|
109
|
+
entry.size_bytes > MAX_BYTES ||
|
|
110
|
+
!Number.isFinite(entry.last_used_at) ||
|
|
111
|
+
!Number.isFinite(Date.parse(entry.checked_at)))
|
|
112
|
+
fail();
|
|
113
|
+
}
|
|
114
|
+
for (const [key, pin] of Object.entries(candidate.pins)) {
|
|
115
|
+
if (!/^[a-f0-9]{64}$/u.test(key) ||
|
|
116
|
+
!pin ||
|
|
117
|
+
!Number.isFinite(pin.expires_at) ||
|
|
118
|
+
!Object.hasOwn(candidate.entries, pin.key) ||
|
|
119
|
+
!pin.copy ||
|
|
120
|
+
pin.copy.path !== candidate.entries[pin.key]?.path ||
|
|
121
|
+
pin.copy.sha256 !== candidate.entries[pin.key]?.sha256 ||
|
|
122
|
+
pin.copy.source_ref !== candidate.entries[pin.key]?.source_ref ||
|
|
123
|
+
pin.copy.size_bytes !== candidate.entries[pin.key]?.size_bytes ||
|
|
124
|
+
typeof pin.copy.media_type !== "string" ||
|
|
125
|
+
pin.copy.media_type.length > 128 ||
|
|
126
|
+
typeof pin.copy.revision !== "string" ||
|
|
127
|
+
!pin.copy.revision ||
|
|
128
|
+
pin.copy.revision.length > 256 ||
|
|
129
|
+
!Number.isFinite(Date.parse(pin.copy.checked_at)))
|
|
130
|
+
fail();
|
|
131
|
+
}
|
|
132
|
+
return candidate;
|
|
133
|
+
}
|
|
134
|
+
save() {
|
|
135
|
+
regular(this.ledgerFile);
|
|
136
|
+
const temporary = `${this.ledgerFile}.${randomUUID()}.tmp`;
|
|
137
|
+
writeFileSync(temporary, JSON.stringify(this.ledger), { flag: "wx", mode: 0o600 });
|
|
138
|
+
renameSync(temporary, this.ledgerFile);
|
|
139
|
+
}
|
|
140
|
+
bytes(entry) {
|
|
141
|
+
assertReferenceCacheIsolation(this.workspace, true);
|
|
142
|
+
const directory = join(this.workspace, REFERENCE_CACHE_PATH, entry.source_ref);
|
|
143
|
+
const stat = lstatSync(directory, { throwIfNoEntry: false });
|
|
144
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
145
|
+
fail();
|
|
146
|
+
const path = join(this.workspace, entry.path);
|
|
147
|
+
if (!regular(path))
|
|
148
|
+
throw new ReferenceCacheError("reference_cache_missing");
|
|
149
|
+
if (lstatSync(path).size !== entry.size_bytes)
|
|
150
|
+
fail();
|
|
151
|
+
const content = readFileSync(path);
|
|
152
|
+
if (hash(content) !== entry.sha256)
|
|
153
|
+
fail();
|
|
154
|
+
return content;
|
|
155
|
+
}
|
|
156
|
+
/** A pin belongs to a server-issued invocation, never to model-supplied identity. */
|
|
157
|
+
async acquire(input) {
|
|
158
|
+
if (!SAFE_REF.test(input.sourceRef) ||
|
|
159
|
+
!SAFE_REF.test(input.invocationRef) ||
|
|
160
|
+
!Number.isFinite(input.expiresAt) ||
|
|
161
|
+
input.expiresAt <= this.now() ||
|
|
162
|
+
input.expiresAt > this.now() + MAX_PIN_MS)
|
|
163
|
+
fail();
|
|
164
|
+
const operation = this.tail.then(async () => {
|
|
165
|
+
if (input.expiresAt <= this.now())
|
|
166
|
+
throw new ReferenceCacheError("reference_invocation_expired");
|
|
167
|
+
assertReferenceCacheIsolation(this.workspace, true);
|
|
168
|
+
const pinKey = hash(Buffer.from(`${input.invocationRef}\0${input.sourceRef}`));
|
|
169
|
+
const pin = this.ledger.pins[pinKey];
|
|
170
|
+
if (pin !== undefined && pin.expires_at > this.now()) {
|
|
171
|
+
const entry = this.ledger.entries[pin.key];
|
|
172
|
+
if (entry === undefined)
|
|
173
|
+
fail();
|
|
174
|
+
const content = this.bytes(entry);
|
|
175
|
+
entry.last_used_at = this.now();
|
|
176
|
+
this.save();
|
|
177
|
+
return { copy: pin.copy, cache_hit: true, content };
|
|
178
|
+
}
|
|
179
|
+
// A new invocation checks access and content. Failures never silently use stale bytes.
|
|
180
|
+
const fetched = await input.fetch();
|
|
181
|
+
assertReferenceCacheIsolation(this.workspace, true);
|
|
182
|
+
if (input.expiresAt <= this.now())
|
|
183
|
+
throw new ReferenceCacheError("reference_invocation_expired");
|
|
184
|
+
if (fetched.content.byteLength > MAX_BYTES ||
|
|
185
|
+
!fetched.revision ||
|
|
186
|
+
fetched.revision.length > 256 ||
|
|
187
|
+
!fetched.media_type ||
|
|
188
|
+
fetched.media_type.length > 128)
|
|
189
|
+
fail();
|
|
190
|
+
const content = Buffer.from(fetched.content);
|
|
191
|
+
const digest = hash(content);
|
|
192
|
+
const key = `${input.sourceRef}:${digest}`;
|
|
193
|
+
const existing = this.ledger.entries[key];
|
|
194
|
+
this.prune(content.byteLength, key);
|
|
195
|
+
const entry = {
|
|
196
|
+
source_ref: input.sourceRef,
|
|
197
|
+
revision: fetched.revision,
|
|
198
|
+
sha256: digest,
|
|
199
|
+
path: relativeCopyPath(input.sourceRef, digest),
|
|
200
|
+
size_bytes: content.byteLength,
|
|
201
|
+
media_type: fetched.media_type,
|
|
202
|
+
checked_at: new Date(this.now()).toISOString(),
|
|
203
|
+
last_used_at: this.now(),
|
|
204
|
+
};
|
|
205
|
+
ensureDirectories(join(this.workspace, REFERENCE_CACHE_PATH, input.sourceRef));
|
|
206
|
+
const path = join(this.workspace, entry.path);
|
|
207
|
+
if (existing !== undefined && regular(path))
|
|
208
|
+
this.bytes(existing);
|
|
209
|
+
else if (regular(path)) {
|
|
210
|
+
// Response-loss/crash orphan: adopt only an exact content-addressed regular file.
|
|
211
|
+
if (!readFileSync(path).equals(content))
|
|
212
|
+
fail();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
216
|
+
writeFileSync(temporary, content, { flag: "wx", mode: 0o600 });
|
|
217
|
+
renameSync(temporary, path);
|
|
218
|
+
}
|
|
219
|
+
// Each version records its own observation. Pins capture revision independently below.
|
|
220
|
+
this.ledger.entries[key] = entry;
|
|
221
|
+
this.ledger.pins[pinKey] = { key, expires_at: input.expiresAt, copy: this.projection(entry) };
|
|
222
|
+
this.save();
|
|
223
|
+
return { copy: this.projection(entry), cache_hit: existing !== undefined, content };
|
|
224
|
+
});
|
|
225
|
+
this.tail = operation.catch(() => undefined);
|
|
226
|
+
return operation;
|
|
227
|
+
}
|
|
228
|
+
projection(entry) {
|
|
229
|
+
return {
|
|
230
|
+
source_ref: entry.source_ref,
|
|
231
|
+
revision: entry.revision,
|
|
232
|
+
sha256: entry.sha256,
|
|
233
|
+
path: entry.path,
|
|
234
|
+
size_bytes: entry.size_bytes,
|
|
235
|
+
media_type: entry.media_type,
|
|
236
|
+
checked_at: entry.checked_at,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
prune(incomingBytes, incomingKey) {
|
|
240
|
+
const now = this.now();
|
|
241
|
+
for (const [key, pin] of Object.entries(this.ledger.pins)) {
|
|
242
|
+
if (pin.expires_at <= now)
|
|
243
|
+
delete this.ledger.pins[key];
|
|
244
|
+
}
|
|
245
|
+
const protectedKeys = new Set(Object.values(this.ledger.pins).map((pin) => pin.key));
|
|
246
|
+
const entries = Object.entries(this.ledger.entries).sort((a, b) => a[1].last_used_at - b[1].last_used_at);
|
|
247
|
+
let total = entries.reduce((sum, [, entry]) => sum + entry.size_bytes, 0);
|
|
248
|
+
const additional = Object.hasOwn(this.ledger.entries, incomingKey) ? 0 : incomingBytes;
|
|
249
|
+
const maximum = this.options.maxTotalBytes ?? 100 * 1024 * 1024;
|
|
250
|
+
const retention = this.options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
251
|
+
for (const [key, entry] of entries) {
|
|
252
|
+
if (key === incomingKey || protectedKeys.has(key))
|
|
253
|
+
continue;
|
|
254
|
+
if (total + additional <= maximum && entry.last_used_at + retention > now)
|
|
255
|
+
continue;
|
|
256
|
+
if (regular(join(this.workspace, entry.path))) {
|
|
257
|
+
this.bytes(entry); // Reject replacement/symlink/tampering before removal.
|
|
258
|
+
unlinkSync(join(this.workspace, entry.path));
|
|
259
|
+
}
|
|
260
|
+
delete this.ledger.entries[key];
|
|
261
|
+
total -= entry.size_bytes;
|
|
262
|
+
}
|
|
263
|
+
this.save();
|
|
264
|
+
if (total + additional > maximum)
|
|
265
|
+
throw new ReferenceCacheError("reference_cache_capacity");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SqliteDatabase } from "../store/index.js";
|
|
2
|
+
import type { ReferenceCopy } from "./cache.js";
|
|
3
|
+
import type { ReferenceInvocation } from "./service.js";
|
|
4
|
+
/** Durable observation, separate from evictable cache metadata; no content or locator. */
|
|
5
|
+
export declare function recordExternalReferenceRead(db: SqliteDatabase, invocation: ReferenceInvocation, copy: ReferenceCopy): void;
|
|
6
|
+
//# sourceMappingURL=read-evidence.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Durable observation, separate from evictable cache metadata; no content or locator. */
|
|
2
|
+
export function recordExternalReferenceRead(db, invocation, copy) {
|
|
3
|
+
const invocationRef = invocation.reference_invocation_ref;
|
|
4
|
+
const existing = db
|
|
5
|
+
.prepare(`
|
|
6
|
+
SELECT revision, content_sha256 FROM external_reference_reads WHERE invocation_ref = ? AND source_ref = ?`)
|
|
7
|
+
.get(invocationRef, copy.source_ref);
|
|
8
|
+
if (existing &&
|
|
9
|
+
(existing.revision !== copy.revision || existing.content_sha256 !== copy.sha256)) {
|
|
10
|
+
throw new Error("reference_read_evidence_conflict");
|
|
11
|
+
}
|
|
12
|
+
db.prepare(`INSERT OR IGNORE INTO external_reference_reads
|
|
13
|
+
(invocation_ref, activity_ref, source_ref, revision, content_sha256, size_bytes, checked_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(invocationRef, invocation.activity_ref ?? null, copy.source_ref, copy.revision, copy.sha256, copy.size_bytes, copy.checked_at);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=read-evidence.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExternalReferenceDescriptor } from "@tutti/shared/schemas/api";
|
|
2
|
+
import type { ReferenceCopy, ReferenceFetchResult } from "./cache.js";
|
|
3
|
+
import { type ExternalReferenceCache } from "./cache.js";
|
|
4
|
+
export type ReferenceInvocation = {
|
|
5
|
+
reference_invocation_ref: string;
|
|
6
|
+
activity_ref?: string;
|
|
7
|
+
expires_at: string;
|
|
8
|
+
};
|
|
9
|
+
export declare class ExternalReferenceAccess {
|
|
10
|
+
private readonly options;
|
|
11
|
+
private cache;
|
|
12
|
+
private readonly selections;
|
|
13
|
+
constructor(options: {
|
|
14
|
+
createCache: () => ExternalReferenceCache;
|
|
15
|
+
list: (invocation: ReferenceInvocation) => Promise<ExternalReferenceDescriptor[]>;
|
|
16
|
+
read: (sourceRef: string, invocation: ReferenceInvocation) => Promise<ReferenceFetchResult>;
|
|
17
|
+
recordRead: (invocation: ReferenceInvocation, copy: ReferenceCopy) => void;
|
|
18
|
+
});
|
|
19
|
+
list(invocation: ReferenceInvocation): Promise<ExternalReferenceDescriptor[]>;
|
|
20
|
+
read(invocation: ReferenceInvocation, sourceRef: string): Promise<{
|
|
21
|
+
copy: ReferenceCopy;
|
|
22
|
+
cache_hit: boolean;
|
|
23
|
+
content: Buffer;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ReferenceCacheError } from "./cache.js";
|
|
2
|
+
export class ExternalReferenceAccess {
|
|
3
|
+
options;
|
|
4
|
+
cache;
|
|
5
|
+
selections = new Map();
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
}
|
|
9
|
+
async list(invocation) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
for (const [key, selection] of this.selections)
|
|
12
|
+
if (selection.expires <= now)
|
|
13
|
+
this.selections.delete(key);
|
|
14
|
+
let selection = this.selections.get(invocation.reference_invocation_ref);
|
|
15
|
+
if (!selection) {
|
|
16
|
+
if (this.selections.size >= 128)
|
|
17
|
+
throw new ReferenceCacheError("reference_reader_busy");
|
|
18
|
+
selection = {
|
|
19
|
+
expires: Date.parse(invocation.expires_at),
|
|
20
|
+
sources: this.options.list(invocation),
|
|
21
|
+
};
|
|
22
|
+
this.selections.set(invocation.reference_invocation_ref, selection);
|
|
23
|
+
selection.sources.catch(() => this.selections.delete(invocation.reference_invocation_ref));
|
|
24
|
+
}
|
|
25
|
+
return selection.sources;
|
|
26
|
+
}
|
|
27
|
+
async read(invocation, sourceRef) {
|
|
28
|
+
const sources = await this.list(invocation);
|
|
29
|
+
if (!sources.some((source) => source.source_ref === sourceRef))
|
|
30
|
+
throw new ReferenceCacheError("reference_source_not_selected");
|
|
31
|
+
this.cache ??= this.options.createCache();
|
|
32
|
+
const result = await this.cache.acquire({
|
|
33
|
+
sourceRef,
|
|
34
|
+
invocationRef: invocation.reference_invocation_ref,
|
|
35
|
+
expiresAt: Date.parse(invocation.expires_at),
|
|
36
|
+
fetch: () => this.options.read(sourceRef, invocation),
|
|
37
|
+
});
|
|
38
|
+
this.options.recordRead(invocation, result.copy);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ExternalReferenceAccess } from "../../reference-access/service.js";
|
|
2
|
+
import type { HostProjectStore } from "../../store/index.js";
|
|
3
|
+
import { type StagedUploadResolver } from "../http/routes/project-api/staged-uploads.js";
|
|
4
|
+
import type { HostLocalLaunchStatus } from "../http/routes/local-control.js";
|
|
5
|
+
import type { LaunchPreparationResult } from "./launch.js";
|
|
6
|
+
export declare function createExternalReferenceRuntime(options: {
|
|
7
|
+
preparation: LaunchPreparationResult;
|
|
8
|
+
store: HostProjectStore;
|
|
9
|
+
resolveStagedUpload: StagedUploadResolver;
|
|
10
|
+
getLaunchStatus: () => HostLocalLaunchStatus;
|
|
11
|
+
}): ExternalReferenceAccess;
|
|
12
|
+
//# sourceMappingURL=external-reference-runtime.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { requestExternalReferences } from "@tutti/relay-client";
|
|
4
|
+
import { isExternalReferenceList, isExternalReferenceReadResult, } from "@tutti/shared/schemas/internal";
|
|
5
|
+
import { ExternalReferenceCache } from "../../reference-access/cache.js";
|
|
6
|
+
import { ExternalReferenceAccess, } from "../../reference-access/service.js";
|
|
7
|
+
import { recordExternalReferenceRead } from "../../reference-access/read-evidence.js";
|
|
8
|
+
import { downloadStagedUploadWithResolver, cleanupDownloadedStagedUpload, } from "../http/routes/project-api/staged-uploads.js";
|
|
9
|
+
import { readHostRegistrationSecret } from "./machine-local.js";
|
|
10
|
+
export function createExternalReferenceRuntime(options) {
|
|
11
|
+
const { preparation } = options;
|
|
12
|
+
async function request(action, invocation, sourceRef) {
|
|
13
|
+
const relay = options.getLaunchStatus().relay;
|
|
14
|
+
if (!relay)
|
|
15
|
+
throw new Error("reference_host_offline");
|
|
16
|
+
const secret = readHostRegistrationSecret(preparation.tutti_home, preparation.project_id);
|
|
17
|
+
return requestExternalReferences({
|
|
18
|
+
relayUrl: preparation.relay_url,
|
|
19
|
+
body: {
|
|
20
|
+
project_identity: { source: "git_config", project_id: preparation.project_id },
|
|
21
|
+
host_auth: { kind: "host_registration_secret_v1", secret: secret.secret },
|
|
22
|
+
relay_project_ref: relay.relay_project_ref,
|
|
23
|
+
host_connection_ref: relay.host_connection_ref,
|
|
24
|
+
request_ref: `eref_${randomUUID().replaceAll("-", "")}`,
|
|
25
|
+
invocation_ref: invocation.reference_invocation_ref,
|
|
26
|
+
action,
|
|
27
|
+
...(sourceRef === undefined ? {} : { source_ref: sourceRef }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new ExternalReferenceAccess({
|
|
32
|
+
createCache: () => new ExternalReferenceCache({
|
|
33
|
+
workspaceRoot: preparation.workspace_root,
|
|
34
|
+
ledgerRoot: join(preparation.tutti_home, "projects", preparation.project_id, "reference-cache"),
|
|
35
|
+
}),
|
|
36
|
+
list: async (invocation) => {
|
|
37
|
+
const result = await request("list", invocation);
|
|
38
|
+
if (!isExternalReferenceList(result))
|
|
39
|
+
throw new Error("reference_list_invalid");
|
|
40
|
+
return result.items;
|
|
41
|
+
},
|
|
42
|
+
read: async (sourceRef, invocation) => {
|
|
43
|
+
const result = await request("read", invocation, sourceRef);
|
|
44
|
+
if (!isExternalReferenceReadResult(result) || result.source.source_ref !== sourceRef)
|
|
45
|
+
throw new Error("reference_read_invalid");
|
|
46
|
+
if (result.size_bytes === 0)
|
|
47
|
+
return {
|
|
48
|
+
content: Buffer.alloc(0),
|
|
49
|
+
revision: result.revision,
|
|
50
|
+
media_type: result.media_type,
|
|
51
|
+
};
|
|
52
|
+
if (!result.upload_id)
|
|
53
|
+
throw new Error("reference_read_invalid");
|
|
54
|
+
const downloaded = await downloadStagedUploadWithResolver({
|
|
55
|
+
resolveStagedUpload: options.resolveStagedUpload,
|
|
56
|
+
purpose: "reference_file",
|
|
57
|
+
expected: {
|
|
58
|
+
upload_id: result.upload_id,
|
|
59
|
+
file_name: `${sourceRef}.bin`,
|
|
60
|
+
size_bytes: result.size_bytes,
|
|
61
|
+
sha256: `sha256:${result.sha256}`,
|
|
62
|
+
media_type: result.media_type,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
await cleanupDownloadedStagedUpload(downloaded);
|
|
66
|
+
return {
|
|
67
|
+
content: downloaded.content,
|
|
68
|
+
revision: result.revision,
|
|
69
|
+
media_type: result.media_type,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
recordRead: (invocation, copy) => recordExternalReferenceRead(options.store.db, invocation, copy),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=external-reference-runtime.js.map
|