clay-server 5.2.0-beta.4 → 5.2.0-beta.5
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/lib/account-auth-socket.js +1 -0
- package/lib/builtin-mates.js +17 -14
- package/lib/canonical-clay-record-authority.js +15 -0
- package/lib/canonical-clay-scheduling-authority.js +19 -0
- package/lib/claude-hook-installer.js +28 -0
- package/lib/clay-mate-chat-receipts.js +26 -0
- package/lib/clay-mate-chat.js +87 -0
- package/lib/clay-mate-knowledge-input.js +26 -0
- package/lib/clay-mate-knowledge.js +95 -0
- package/lib/clay-mate-lifecycle.js +95 -0
- package/lib/clay-product-control-input.js +33 -0
- package/lib/clay-product-control-tools.js +51 -0
- package/lib/clay-product-control.js +237 -0
- package/lib/clay-project-lifecycle.js +154 -0
- package/lib/clay-project-record-input.js +44 -0
- package/lib/clay-project-records.js +83 -0
- package/lib/clay-scheduling-input.js +21 -0
- package/lib/clay-scheduling-receipts.js +26 -0
- package/lib/clay-scheduling.js +119 -0
- package/lib/clay-screen-controls.js +96 -0
- package/lib/clay-workspace-controls.js +83 -0
- package/lib/clay-workspace-input.js +17 -0
- package/lib/clay-workspace-receipts.js +26 -0
- package/lib/daemon-project-access.js +11 -0
- package/lib/daemon-project-lifecycle-callbacks.js +15 -0
- package/lib/daemon-project-lifecycle.js +262 -0
- package/lib/daemon-projects.js +1 -1
- package/lib/daemon.js +73 -221
- package/lib/issues-service.js +29 -15
- package/lib/knowledge-db-import.js +5 -4
- package/lib/knowledge-db-service.js +9 -6
- package/lib/knowledge-document-index.js +2 -1
- package/lib/knowledge-product-operations.js +46 -0
- package/lib/mate-creation-mcp-server.js +2 -0
- package/lib/mate-knowledge-canonical-access.js +10 -0
- package/lib/mate-knowledge-service.js +7 -4
- package/lib/mate-lifecycle-service.js +94 -0
- package/lib/mate-message-delivery.js +43 -0
- package/lib/mate-ready-creation.js +9 -1
- package/lib/mate-schedule-input.js +29 -0
- package/lib/mate-schedule-lifecycle.js +151 -0
- package/lib/mate-scheduling.js +103 -0
- package/lib/mates.js +4 -3
- package/lib/notes-checklist.js +50 -0
- package/lib/notes.js +49 -3
- package/lib/os-users.js +20 -5
- package/lib/project-admin-overview.js +51 -0
- package/lib/project-ask-user.js +4 -2
- package/lib/project-delegated-follow-up.js +2 -1
- package/lib/project-directory-claim.js +65 -0
- package/lib/project-file-operations.js +61 -0
- package/lib/project-filesystem.js +13 -87
- package/lib/project-full-access.js +23 -2
- package/lib/project-knowledge.js +32 -18
- package/lib/project-lifecycle-operations.js +98 -0
- package/lib/project-lifecycle-process-witness.js +32 -0
- package/lib/project-lifecycle-runtime.js +50 -0
- package/lib/project-linux-access.js +22 -0
- package/lib/project-logs-canonical-clay.js +27 -0
- package/lib/project-logs-clay-query.js +16 -0
- package/lib/project-logs-schema.js +4 -2
- package/lib/project-logs-service.js +8 -7
- package/lib/project-logs.js +1 -0
- package/lib/project-loop.js +1 -0
- package/lib/project-mate-chat-control.js +55 -0
- package/lib/project-mate-creation-proposal.js +18 -3
- package/lib/project-models.js +14 -31
- package/lib/project-notifications.js +74 -16
- package/lib/project-ownership.js +77 -0
- package/lib/project-pair-result-delivery.js +14 -3
- package/lib/project-pending-message-queue.js +1 -0
- package/lib/project-product-control.js +87 -0
- package/lib/project-product-session-queue.js +46 -0
- package/lib/project-push-access.js +28 -0
- package/lib/project-scheduled-message-queue.js +30 -0
- package/lib/project-scheduled-messages.js +63 -64
- package/lib/project-scheduled-task-control.js +83 -0
- package/lib/project-scheduled-tasks.js +13 -2
- package/lib/project-scheduling-access.js +10 -0
- package/lib/project-session-actions.js +65 -0
- package/lib/project-session-delete.js +8 -1
- package/lib/project-session-github.js +47 -12
- package/lib/project-session-history-actions.js +165 -0
- package/lib/project-session-runtime-actions.js +63 -0
- package/lib/project-session-vendor.js +54 -0
- package/lib/project-sessions.js +64 -241
- package/lib/project-user-message.js +30 -1
- package/lib/project-workspace-operations.js +126 -0
- package/lib/project-workspace-query.js +9 -2
- package/lib/project.js +95 -3
- package/lib/public/css/clay-tool-activity.css +51 -0
- package/lib/public/css/home-mate-creation.css +5 -0
- package/lib/public/css/overlays.css +4 -2
- package/lib/public/css/project-admin-overview.css +13 -0
- package/lib/public/css/session-folders.css +11 -0
- package/lib/public/css/session-github.css +3 -0
- package/lib/public/css/sticky-notes.css +61 -1
- package/lib/public/index.html +1 -1
- package/lib/public/modules/admin-settings-projects.js +6 -2
- package/lib/public/modules/app-connection.js +8 -1
- package/lib/public/modules/app-message-router.js +1 -1
- package/lib/public/modules/app-messages.js +18 -3
- package/lib/public/modules/app-projects.js +5 -0
- package/lib/public/modules/clay-owner-navigation.js +27 -0
- package/lib/public/modules/clay-tool-activity.js +140 -0
- package/lib/public/modules/clay-tool-presentation.js +338 -0
- package/lib/public/modules/command-palette.js +1 -1
- package/lib/public/modules/default-ai.js +11 -3
- package/lib/public/modules/home-debate-planning.js +5 -5
- package/lib/public/modules/home-mate-chat.js +8 -6
- package/lib/public/modules/home-mate-creation.js +73 -1
- package/lib/public/modules/personal-settings-dialog.js +7 -1
- package/lib/public/modules/project-access-recovery.js +17 -0
- package/lib/public/modules/project-admin-overview.js +21 -0
- package/lib/public/modules/session-folder-layout.js +1 -1
- package/lib/public/modules/session-folder-view-menu.js +9 -0
- package/lib/public/modules/session-github.js +65 -8
- package/lib/public/modules/sidebar-session-folders-ui.js +1 -1
- package/lib/public/modules/sticky-note-markdown.js +72 -6
- package/lib/public/modules/sticky-notes-actions.js +30 -0
- package/lib/public/modules/sticky-notes-browser.js +9 -2
- package/lib/public/modules/sticky-notes-card.js +23 -7
- package/lib/public/modules/sticky-notes-checklist.js +71 -0
- package/lib/public/modules/sticky-notes-editor.js +108 -44
- package/lib/public/modules/sticky-notes-shared.js +50 -2
- package/lib/public/modules/sticky-notes-state.js +66 -0
- package/lib/public/modules/sticky-notes.js +34 -51
- package/lib/public/modules/thinking-lifecycle.js +33 -3
- package/lib/public/modules/thinking-view.js +23 -1
- package/lib/public/modules/tools.js +34 -6
- package/lib/public/modules/user-settings-appearance.js +10 -3
- package/lib/public/modules/user-settings-editors.js +1 -1
- package/lib/public/modules/user-settings-layout.js +11 -2
- package/lib/public/modules/user-settings-preferences.js +6 -2
- package/lib/public/modules/user-settings-profile.js +12 -2
- package/lib/public/modules/user-settings.js +3 -2
- package/lib/public/style.css +2 -0
- package/lib/scheduled-task-records.js +3 -1
- package/lib/scheduler.js +2 -0
- package/lib/sdk-bridge.js +220 -21
- package/lib/sdk-message-processor.js +2 -0
- package/lib/sdk-response-recovery.js +155 -0
- package/lib/server-admin.js +63 -16
- package/lib/server-dm.js +1 -1
- package/lib/server-global-ws.js +10 -20
- package/lib/server-home-chat-events.js +7 -3
- package/lib/server-home-chat.js +4 -16
- package/lib/server-home-mate-creation.js +1 -0
- package/lib/server-home-mcp-consent.js +3 -3
- package/lib/server-mates.js +25 -67
- package/lib/server-owner-ui.js +77 -0
- package/lib/server-path-access.js +38 -0
- package/lib/server-project-removal.js +22 -0
- package/lib/server.js +99 -29
- package/lib/session-config-transition.js +45 -0
- package/lib/session-folders.js +7 -2
- package/lib/session-github.js +9 -2
- package/lib/session-visibility.js +5 -2
- package/lib/sessions.js +10 -0
- package/lib/terminal-manager.js +52 -0
- package/lib/users-permissions.js +2 -8
- package/lib/users.js +4 -0
- package/lib/workspace-query-access.js +4 -1
- package/lib/ws-schema.js +8 -2
- package/lib/yoke/adapters/claude.js +47 -8
- package/lib/yoke/adapters/codex.js +52 -111
- package/lib/yoke/claude-worker-startup.js +28 -0
- package/lib/yoke/codex-idle-recovery.js +81 -1
- package/lib/yoke/codex-recovery-admission.js +164 -0
- package/lib/yoke/session-tool-transport.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
var crypto = require('crypto');
|
|
2
|
+
var userInput = require('./yoke/user-input');
|
|
3
|
+
var input = require('./mate-schedule-input');
|
|
4
|
+
var personal = require('./mcp-session-access');
|
|
5
|
+
|
|
6
|
+
function attachMateScheduling(ctx) {
|
|
7
|
+
function owner(session) { return ctx.users.isMultiUser() ? session.ownerId : 'local-user'; }
|
|
8
|
+
function privateSession(session) {
|
|
9
|
+
if (!ctx.isMate || !session || ctx.sm.sessions.get(session.localId) !== session ||
|
|
10
|
+
session.destroying || session.mateCreationMode || session.taskStopRequested || session._mcpForeignInput ||
|
|
11
|
+
session.sessionVisibility === 'shared' || session.delegated || session._delegatedBy || session._pairDelegation) throw new Error('An active private Mate conversation is required.');
|
|
12
|
+
if (ctx.users.isMultiUser() && (session.ownerId !== ctx.projectOwnerId || !personal.canUsePersonalConnections(session, ctx.sm.sessions))) throw new Error('Only the Mate owner can schedule messages.');
|
|
13
|
+
if (!ctx.users.isMultiUser() && (session.ownerId || (session.history || []).some(function (event) { return event.type === 'user_message' && event.from && event.from !== 'default'; }))) throw new Error('Foreign input cannot authorize Mate schedules.');
|
|
14
|
+
ctx.authorizeAccess(session, owner(session));
|
|
15
|
+
}
|
|
16
|
+
function authorize(session) {
|
|
17
|
+
if (ctx.isHostAgent) throw new Error('Canonical Clay scheduling requires its separate owner-reviewed controls.');
|
|
18
|
+
privateSession(session);
|
|
19
|
+
}
|
|
20
|
+
function authorizeControl(session, control) {
|
|
21
|
+
privateSession(session);
|
|
22
|
+
return require('./canonical-clay-scheduling-authority').validate(control, ctx.slug, session);
|
|
23
|
+
}
|
|
24
|
+
function authorizeLifecycle(session) {
|
|
25
|
+
privateSession(session);
|
|
26
|
+
require('./canonical-clay-scheduling-authority').assertMate(ctx.projectOwnerId, ctx.mateId, ctx.users.isMultiUser());
|
|
27
|
+
if (ctx.isHostAgent) require('./canonical-clay-scheduling-authority').assertHost(ctx.projectOwnerId, ctx.mateId, ctx.users.isMultiUser());
|
|
28
|
+
}
|
|
29
|
+
function persistDraft(session, draft) {
|
|
30
|
+
var previous = session.mateScheduleDraft;
|
|
31
|
+
session.mateScheduleDraft = draft;
|
|
32
|
+
if (ctx.sm.saveSessionFile(session) !== true) {
|
|
33
|
+
session.mateScheduleDraft = previous;
|
|
34
|
+
throw new Error('The schedule draft could not be saved.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function result(value) { return { content: [{ type: 'text', text: JSON.stringify(value) }] }; }
|
|
38
|
+
function getToolDefs(session) {
|
|
39
|
+
try { authorize(session); } catch (error) { return []; }
|
|
40
|
+
var generation = Number(session._sdkQueryGeneration || 0);
|
|
41
|
+
var originalOwner = owner(session);
|
|
42
|
+
function current() {
|
|
43
|
+
authorize(session);
|
|
44
|
+
if (generation !== Number(session._sdkQueryGeneration || 0) || owner(session) !== originalOwner) throw new Error('The schedule query is stale.');
|
|
45
|
+
}
|
|
46
|
+
function tool(name, description, properties, required, handler) {
|
|
47
|
+
return { name: name, queryBound: true, inputSchema: { type: 'object', properties: properties, required: required, additionalProperties: false },
|
|
48
|
+
description: description, handler: async function (args) {
|
|
49
|
+
try { current(); return result(await handler(args || {})); }
|
|
50
|
+
catch (error) { return result({ status: 'rejected', reason: error.message }); }
|
|
51
|
+
} };
|
|
52
|
+
}
|
|
53
|
+
var str = { type: 'string' };
|
|
54
|
+
var fields = { message: str, date: str, time: str, scheduleRule: { type: ['object', 'null'] } };
|
|
55
|
+
async function approve(draft, question, label, other) {
|
|
56
|
+
persistDraft(session, draft);
|
|
57
|
+
var reply = await userInput.dispatchUserInput(ctx.userInput(session), { questions: [{
|
|
58
|
+
id: 'approve', header: 'Mate schedule', question: question,
|
|
59
|
+
options: [{ label: label }, { label: other || 'Cancel' }], allowOther: false,
|
|
60
|
+
}] }, { source: 'mate-schedule', signal: session.abortController && session.abortController.signal });
|
|
61
|
+
current();
|
|
62
|
+
if (!session.mateScheduleDraft || session.mateScheduleDraft.id !== draft.id || session.mateScheduleDraft.revision !== draft.revision) throw new Error('The schedule draft is stale.');
|
|
63
|
+
var accepted = reply.status === 'submitted' && reply.answers.approve && reply.answers.approve.length === 1 && reply.answers.approve[0] === label;
|
|
64
|
+
if (!accepted) persistDraft(session, null);
|
|
65
|
+
return accepted;
|
|
66
|
+
}
|
|
67
|
+
async function review(args, update) {
|
|
68
|
+
var previous = update ? ctx.lifecycle.read(session, args) : null;
|
|
69
|
+
var value = input.normalize(args, previous);
|
|
70
|
+
var draft = { id: crypto.randomUUID(), revision: crypto.randomUUID(), value: value,
|
|
71
|
+
targetId: previous && previous.id, targetRevision: previous && previous.revision };
|
|
72
|
+
if (!await approve(draft, (update ? 'Update this schedule?\n' : 'Create this schedule?\n') + input.review(value), update ? 'Update schedule' : 'Create schedule')) return { status: 'cancelled' };
|
|
73
|
+
var record = update ? ctx.lifecycle.update(session, { id: draft.targetId, revision: draft.targetRevision }, value) : ctx.lifecycle.create(session, value, draft.id);
|
|
74
|
+
persistDraft(session, null);
|
|
75
|
+
return { status: update ? 'updated' : 'created', schedule: record, timezone: input.timezone() };
|
|
76
|
+
}
|
|
77
|
+
async function cancel(args) {
|
|
78
|
+
var record = ctx.lifecycle.read(session, args);
|
|
79
|
+
if (record.state === 'cancelled') return { status: 'cancelled', schedule: record };
|
|
80
|
+
var draft = { id: crypto.randomUUID(), revision: crypto.randomUUID(), targetId: record.id, targetRevision: record.revision };
|
|
81
|
+
if (!await approve(draft, 'Cancel this schedule?\n' + input.review({ message: record.message, rule: record.rule }), 'Cancel schedule', 'Keep schedule')) return { status: 'unchanged' };
|
|
82
|
+
record = ctx.lifecycle.cancel(session, { id: draft.targetId, revision: draft.targetRevision });
|
|
83
|
+
persistDraft(session, null);
|
|
84
|
+
return { status: 'cancelled', schedule: record };
|
|
85
|
+
}
|
|
86
|
+
var begin = tool('begin_mate_schedule_interview', 'Begin scheduling for this Mate. Ask exactly one structured question at a time. Never assume repetition.', {}, [], function () {
|
|
87
|
+
return { status: 'interview_started', timezone: input.timezone(), guidance: 'Collect the message, date/time, and explicitly requested repetition one question at a time. Then propose the complete schedule for the human approval card.' };
|
|
88
|
+
});
|
|
89
|
+
begin.structuredQuestionLimit = function () { try { current(); return 1; } catch (error) { return null; } };
|
|
90
|
+
return [begin,
|
|
91
|
+
tool('propose_mate_schedule', 'Review a complete message schedule in a server-owned approval card. Only authenticated human approval persists a schedule.', fields, ['message'], function (args) { return review(args, false); }),
|
|
92
|
+
tool('list_mate_schedules', 'List logical schedules and their stable IDs, revisions and lifecycle states in this private conversation.', {}, [], function () { return { schedules: ctx.lifecycle.list(session) }; }),
|
|
93
|
+
tool('read_mate_schedule', 'Read one logical schedule using its listed ID.', { id: str }, ['id'], function (args) { return { schedule: ctx.lifecycle.read(session, args) }; }),
|
|
94
|
+
tool('update_mate_schedule', 'Propose changes to an active schedule by listed ID and revision, with a fresh human approval card.', Object.assign({ id: str, revision: str }, fields), ['id', 'revision'], function (args) { return review(args, true); }),
|
|
95
|
+
tool('cancel_mate_schedule', 'Review cancellation by listed ID and revision in an authenticated human approval card. Cancellation persists across restart.', { id: str, revision: str }, ['id', 'revision'], cancel),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
return { authorize: authorize, authorizeControl: authorizeControl, authorizeLifecycle: authorizeLifecycle, owner: owner, getToolDefs: getToolDefs, getSystemPrompt: function (session) {
|
|
99
|
+
try { authorize(session); } catch (error) { return ''; }
|
|
100
|
+
return 'When scheduling for this Mate, call begin_mate_schedule_interview, ask exactly one structured question at a time, and clarify the message, local date/time in ' + input.timezone() + ', and explicitly requested repetition. Use scheduleRule for recurrence and count/until limits. propose_mate_schedule and update_mate_schedule present the exact authenticated human approval card. Chat text and model flags never approve schedules. Use listed logical IDs and revisions to read, update or cancel. Scheduled output uses this private Mate runtime and tools. Project Scheduled Tasks Driver/Worker tools remain unavailable.';
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
module.exports = { attachMateScheduling: attachMateScheduling };
|
package/lib/mates.js
CHANGED
|
@@ -246,7 +246,7 @@ function updateMate(ctx, id, updates) {
|
|
|
246
246
|
return null;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
function deleteMate(ctx, id) {
|
|
249
|
+
function deleteMate(ctx, id, options) {
|
|
250
250
|
var data = loadMates(ctx);
|
|
251
251
|
// Primary mates cannot be deleted (they are system infrastructure)
|
|
252
252
|
for (var di = 0; di < data.mates.length; di++) {
|
|
@@ -259,15 +259,16 @@ function deleteMate(ctx, id) {
|
|
|
259
259
|
return m.id !== id;
|
|
260
260
|
});
|
|
261
261
|
if (data.mates.length === before) return { error: "Mate not found" };
|
|
262
|
-
saveMates(ctx, data);
|
|
262
|
+
if (!options || !options.strictCleanup) saveMates(ctx, data);
|
|
263
263
|
|
|
264
264
|
// Remove mate directory
|
|
265
265
|
var mateDir = getMateDir(ctx, id);
|
|
266
266
|
try {
|
|
267
267
|
fs.rmSync(mateDir, { recursive: true, force: true });
|
|
268
268
|
} catch (e) {
|
|
269
|
-
|
|
269
|
+
if (options && options.strictCleanup) throw e;
|
|
270
270
|
}
|
|
271
|
+
if (options && options.strictCleanup) saveMates(ctx, data);
|
|
271
272
|
|
|
272
273
|
return { ok: true };
|
|
273
274
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Bounded source identities shared by checklist validation and reconciliation.
|
|
2
|
+
function items(text) {
|
|
3
|
+
var lines = (text || '').split('\n').slice(1);
|
|
4
|
+
var occurrences = Object.create(null);
|
|
5
|
+
var out = [];
|
|
6
|
+
var fence = null;
|
|
7
|
+
for (var i = 0; i < lines.length; i++) {
|
|
8
|
+
var line = lines[i];
|
|
9
|
+
var match = line.match(/^\s*(\x60{3,}|~{3,})/);
|
|
10
|
+
if (match) { fence = fence === match[1].charAt(0) ? null : (fence || match[1].charAt(0)); continue; }
|
|
11
|
+
if (fence || !line.trim() || /^\s*#{1,6}\s/.test(line)) continue;
|
|
12
|
+
var occurrence = occurrences[line] || 0;
|
|
13
|
+
occurrences[line] = occurrence + 1;
|
|
14
|
+
var key = line + '\u0000' + occurrence;
|
|
15
|
+
if (i <= 1000 && key.length <= 2000 && encodeURIComponent(key).length <= 2000) out.push({ index: i, key: key });
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validState(value) {
|
|
21
|
+
if (!Array.isArray(value) && (!value || typeof value !== 'object')) return false;
|
|
22
|
+
var keys = Object.keys(value);
|
|
23
|
+
if (keys.length > 1000) return false;
|
|
24
|
+
for (var i = 0; i < keys.length; i++) {
|
|
25
|
+
if (keys[i].length > 2000 || typeof value[keys[i]] !== 'boolean') return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeState(value, text) {
|
|
31
|
+
var state = {};
|
|
32
|
+
items(text).forEach(function (item) {
|
|
33
|
+
var checked = value && value[Array.isArray(value) ? item.index : item.key];
|
|
34
|
+
if (typeof checked === 'boolean' && Object.keys(state).length < 1000) state[item.key] = checked;
|
|
35
|
+
});
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateChange(note, changes) {
|
|
40
|
+
if (changes.checklistIndex === undefined) return null;
|
|
41
|
+
if (!Number.isInteger(changes.checklistIndex) || changes.checklistIndex < 0 || changes.checklistIndex > 1000) return null;
|
|
42
|
+
if (typeof changes.checklistChecked !== 'boolean' || typeof changes.checklistSource !== 'string' || typeof changes.checklistKey !== 'string') return null;
|
|
43
|
+
if (changes.checklistSource !== note.text || changes.checklistKey.length > 2000 || (changes.text !== undefined && changes.text !== note.text)) return null;
|
|
44
|
+
var item = items(note.text).find(function (item) { return item.index === changes.checklistIndex; });
|
|
45
|
+
var state = normalizeState(note.checklistState, note.text);
|
|
46
|
+
if (item && state[item.key] === undefined && Object.keys(state).length >= 1000) return null;
|
|
47
|
+
try { return item && item.key === decodeURIComponent(changes.checklistKey) ? item.key : null; } catch (error) { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { validState: validState, normalizeState: normalizeState, validateChange: validateChange };
|
package/lib/notes.js
CHANGED
|
@@ -4,6 +4,7 @@ var crypto = require("crypto");
|
|
|
4
4
|
var config = require("./config");
|
|
5
5
|
var utils = require("./utils");
|
|
6
6
|
var lifecycle = require("./notes-lifecycle");
|
|
7
|
+
var checklist = require("./notes-checklist");
|
|
7
8
|
|
|
8
9
|
function createNotesManager(opts) {
|
|
9
10
|
var cwd = opts.cwd;
|
|
@@ -61,6 +62,8 @@ function createNotesManager(opts) {
|
|
|
61
62
|
h: typeof data.h === "number" ? data.h : 180,
|
|
62
63
|
color: data.color || "purple",
|
|
63
64
|
opacity: typeof data.opacity === "number" ? data.opacity : 0.64,
|
|
65
|
+
checklistMode: data.checklistMode === true,
|
|
66
|
+
checklistState: checklist.validState(data.checklistState) ? checklist.normalizeState(data.checklistState, data.text || "") : [],
|
|
64
67
|
minimized: false,
|
|
65
68
|
// A new note is always an open attention item.
|
|
66
69
|
state: lifecycle.STATE_OPEN,
|
|
@@ -84,13 +87,33 @@ function createNotesManager(opts) {
|
|
|
84
87
|
function update(id, changes) {
|
|
85
88
|
for (var i = 0; i < notes.length; i++) {
|
|
86
89
|
if (notes[i].id === id) {
|
|
87
|
-
|
|
90
|
+
if (changes.checklistConvert === true) {
|
|
91
|
+
if (changes.checklistSource !== notes[i].text || changes.checklistVersion !== notes[i].updatedAt || notes[i].checklistMode === true) return null;
|
|
92
|
+
changes = Object.assign({}, changes, { text: "", checklistState: {}, checklistMode: true });
|
|
93
|
+
}
|
|
94
|
+
if (changes.checklistEdit === true && (notes[i].checklistMode !== true || changes.checklistSource !== notes[i].text || changes.checklistVersion !== notes[i].updatedAt || typeof changes.text !== "string")) return null;
|
|
95
|
+
if (changes.checklistMode !== undefined && typeof changes.checklistMode !== "boolean") return null;
|
|
96
|
+
if (changes.checklistState !== undefined && !checklist.validState(changes.checklistState)) return null;
|
|
97
|
+
var checklistKey = checklist.validateChange(notes[i], changes);
|
|
98
|
+
if (changes.checklistIndex !== undefined && !checklistKey) return null;
|
|
99
|
+
var previous = {};
|
|
100
|
+
var snapshotKeys = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity", "checklistMode", "checklistState", "hidden", "state", "closedAt", "closedBy", "updatedAt"];
|
|
101
|
+
for (var s = 0; s < snapshotKeys.length; s++) previous[snapshotKeys[s]] = notes[i][snapshotKeys[s]];
|
|
102
|
+
var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity", "checklistMode", "checklistState"];
|
|
88
103
|
for (var j = 0; j < allowed.length; j++) {
|
|
89
104
|
var key = allowed[j];
|
|
90
105
|
if (changes[key] !== undefined) {
|
|
91
106
|
notes[i][key] = changes[key];
|
|
92
107
|
}
|
|
93
108
|
}
|
|
109
|
+
if (checklistKey) {
|
|
110
|
+
var state = checklist.normalizeState(notes[i].checklistState, notes[i].text);
|
|
111
|
+
state[checklistKey] = changes.checklistChecked === true;
|
|
112
|
+
notes[i].checklistState = state;
|
|
113
|
+
}
|
|
114
|
+
if (changes.text !== undefined || changes.checklistState !== undefined || checklistKey) {
|
|
115
|
+
notes[i].checklistState = checklist.normalizeState(notes[i].checklistState, notes[i].text);
|
|
116
|
+
}
|
|
94
117
|
// `hidden` is the legacy spelling of the lifecycle. Route it through the
|
|
95
118
|
// transitions so state, hidden, and the close provenance can never
|
|
96
119
|
// disagree, and so an old client cannot half-apply a close.
|
|
@@ -98,8 +121,11 @@ function createNotesManager(opts) {
|
|
|
98
121
|
if (changes.hidden === true) lifecycle.applyClose(notes[i], changes.actor || null, Date.now());
|
|
99
122
|
else lifecycle.applyReopen(notes[i]);
|
|
100
123
|
}
|
|
101
|
-
notes[i].updatedAt = Date.now();
|
|
102
|
-
saveToDisk()
|
|
124
|
+
notes[i].updatedAt = Math.max(Date.now(), (previous.updatedAt || 0) + 1);
|
|
125
|
+
if (!saveToDisk()) {
|
|
126
|
+
for (var p = 0; p < snapshotKeys.length; p++) notes[i][snapshotKeys[p]] = previous[snapshotKeys[p]];
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
103
129
|
return notes[i];
|
|
104
130
|
}
|
|
105
131
|
}
|
|
@@ -120,6 +146,25 @@ function createNotesManager(opts) {
|
|
|
120
146
|
return null;
|
|
121
147
|
}
|
|
122
148
|
|
|
149
|
+
// Human close includes the visible draft and an exact canonical snapshot.
|
|
150
|
+
// Recheck it here, then persist draft + lifecycle/removal in one transaction.
|
|
151
|
+
function closeHuman(id, changes, actor) {
|
|
152
|
+
for (var i = 0; i < notes.length; i++) {
|
|
153
|
+
var note = notes[i];
|
|
154
|
+
if (note.id !== id) continue;
|
|
155
|
+
if (note.text !== changes.expectedText || note.updatedAt !== changes.expectedVersion || typeof changes.draftText !== "string") return null;
|
|
156
|
+
if (!changes.draftText.trim()) {
|
|
157
|
+
notes.splice(i, 1);
|
|
158
|
+
if (saveToDisk()) return { removed: note };
|
|
159
|
+
notes.splice(i, 0, note);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
var updated = update(id, { text: changes.draftText, hidden: true, actor: actor });
|
|
163
|
+
return updated ? { note: updated } : null;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
// Reopen a closed note. Idempotent.
|
|
124
169
|
function reopen(id) {
|
|
125
170
|
for (var i = 0; i < notes.length; i++) {
|
|
@@ -211,6 +256,7 @@ function createNotesManager(opts) {
|
|
|
211
256
|
create: create,
|
|
212
257
|
update: update,
|
|
213
258
|
close: close,
|
|
259
|
+
closeHuman: closeHuman,
|
|
214
260
|
reopen: reopen,
|
|
215
261
|
remove: remove,
|
|
216
262
|
removeClosed: removeClosed,
|
package/lib/os-users.js
CHANGED
|
@@ -153,6 +153,19 @@ function fsAsUser(op, args, osUserInfo) {
|
|
|
153
153
|
});
|
|
154
154
|
var binOutput = execFileSync(binSpawn.command, binSpawn.args, binSpawn.options);
|
|
155
155
|
return { buffer: Buffer.from(binOutput.trim(), "base64") };
|
|
156
|
+
} else if (op === 'write_exact') {
|
|
157
|
+
script = [
|
|
158
|
+
"var fs = require('fs'), crypto = require('crypto'), path = require('path');",
|
|
159
|
+
"var f = " + JSON.stringify(args.file) + "; var expected = " + JSON.stringify(args.expected) + ";",
|
|
160
|
+
"var root = " + JSON.stringify(args.root) + "; var relative = path.relative(root, f);",
|
|
161
|
+
"function checkPath() { if (fs.realpathSync(root) !== root || path.isAbsolute(relative) || relative.split(path.sep).indexOf('..') >= 0) throw new Error('The canonical file boundary changed.'); var at = root; relative.split(path.sep).forEach(function (part) { at = path.join(at, part); if (fs.lstatSync(at).isSymbolicLink()) throw new Error('File parent symlinks are unavailable.'); }); }",
|
|
162
|
+
"checkPath();",
|
|
163
|
+
"var fd = fs.openSync(f, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);",
|
|
164
|
+
"try { var s = fs.fstatSync(fd);",
|
|
165
|
+
"if (!s.isFile() || s.nlink !== 1 || s.dev !== expected.dev || s.ino !== expected.ino || s.mode !== expected.mode || s.uid !== expected.uid || s.gid !== expected.gid || s.size !== expected.size || s.mtimeMs !== expected.mtime || crypto.createHash('sha256').update(fs.readFileSync(fd, 'utf8')).digest('hex') !== expected.hash) throw new Error('The exact file revision changed.');",
|
|
166
|
+
"checkPath(); fs.ftruncateSync(fd, 0); var bytes = Buffer.from(" + JSON.stringify(args.content) + ", 'utf8'), offset = 0; while (offset < bytes.length) { var written = fs.writeSync(fd, bytes, offset, bytes.length - offset, offset); if (!written) throw new Error('File write made no progress.'); offset += written; } fs.fsyncSync(fd); } finally { fs.closeSync(fd); }",
|
|
167
|
+
"process.stdout.write(JSON.stringify({ ok: true }));",
|
|
168
|
+
].join(' ');
|
|
156
169
|
} else if (op === "write") {
|
|
157
170
|
script = [
|
|
158
171
|
"var fs = require('fs');",
|
|
@@ -267,18 +280,18 @@ function isHomeDirectory(dirPath) {
|
|
|
267
280
|
* Uses setfacl to add recursive + default ACL entries.
|
|
268
281
|
* Skips home directories to avoid slow recursive ACL on large trees.
|
|
269
282
|
*/
|
|
270
|
-
function grantProjectAccess(projectPath, linuxUser) {
|
|
283
|
+
function grantProjectAccess(projectPath, linuxUser, options) {
|
|
271
284
|
if (isHomeDirectory(projectPath)) {
|
|
272
285
|
console.log("[os-users] Skipping ACL for home directory: " + projectPath);
|
|
273
|
-
return;
|
|
286
|
+
return true;
|
|
274
287
|
}
|
|
275
288
|
if (!isSafeLinuxUsername(linuxUser)) {
|
|
276
289
|
console.error("[os-users] Invalid Linux username for ACL grant: " + linuxUser);
|
|
277
|
-
return;
|
|
290
|
+
return false;
|
|
278
291
|
}
|
|
279
292
|
var cacheKey = aclCacheKey(projectPath, linuxUser);
|
|
280
|
-
if (_aclGrantCache[cacheKey]) {
|
|
281
|
-
return;
|
|
293
|
+
if (_aclGrantCache[cacheKey] && !(options && options.force)) {
|
|
294
|
+
return true;
|
|
282
295
|
}
|
|
283
296
|
try {
|
|
284
297
|
// Recursive ACL for existing files
|
|
@@ -295,6 +308,7 @@ function grantProjectAccess(projectPath, linuxUser) {
|
|
|
295
308
|
});
|
|
296
309
|
_aclGrantCache[cacheKey] = true;
|
|
297
310
|
console.log("[os-users] Granted ACL access for " + linuxUser + " on " + projectPath);
|
|
311
|
+
return true;
|
|
298
312
|
} catch (e) {
|
|
299
313
|
delete _aclGrantCache[cacheKey];
|
|
300
314
|
var errMsg = (e.stderr || e.message || "").toString();
|
|
@@ -305,6 +319,7 @@ function grantProjectAccess(projectPath, linuxUser) {
|
|
|
305
319
|
} else {
|
|
306
320
|
console.error("[os-users] Failed to grant ACL access for " + linuxUser + " on " + projectPath + ": " + errMsg);
|
|
307
321
|
}
|
|
322
|
+
return false;
|
|
308
323
|
}
|
|
309
324
|
}
|
|
310
325
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// This projection is an allowlist. Never add runtime/session/path objects here.
|
|
2
|
+
function projectMetadata(status, access, users) {
|
|
3
|
+
if (!status || status.isMate || !access || access.error) return null;
|
|
4
|
+
var owner = access.ownerId && users.findUserById(access.ownerId);
|
|
5
|
+
return {
|
|
6
|
+
slug: status.slug,
|
|
7
|
+
project: status.title || status.project || status.slug,
|
|
8
|
+
title: status.title || status.project || status.slug,
|
|
9
|
+
projectOwnerId: owner ? owner.id : null,
|
|
10
|
+
ownerName: owner ? (owner.displayName || owner.username) : "Ownership needs recovery",
|
|
11
|
+
visibility: access.visibility,
|
|
12
|
+
contentAccess: false,
|
|
13
|
+
administrativeAccess: true,
|
|
14
|
+
ownershipRecovery: access.ownershipRecovery === "required" || !owner,
|
|
15
|
+
runtimeHealth: "Registered",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function metadataFor(user, status, access, users) {
|
|
20
|
+
if (!user || !users.findUserById(user.id) || user.role !== "admin" || !status || status.isMate || /^mate-/.test(status.slug)) return null;
|
|
21
|
+
return projectMetadata(status, access, users);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escape(value) {
|
|
25
|
+
return String(value || "").replace(/[&<>"']/g, function (character) {
|
|
26
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function page(metadata, projects) {
|
|
31
|
+
var privacyText = metadata.ownershipRecovery
|
|
32
|
+
? "Content remains restricted because no verified owner is available. Ask the server operator to verify the account’s OS UID and real home/project mapping, then run the documented ownership repair."
|
|
33
|
+
: "Conversations, files and terminals are private. Ask the owner to share project access.";
|
|
34
|
+
var accessText = metadata.ownershipRecovery
|
|
35
|
+
? "Ownership needs recovery. The server operator must independently verify or repair the authoritative account-to-OS mapping before ownership repair can succeed. Ambiguous records stay restricted; administrator status cannot claim this project. See the Project ownership and administrator access guide."
|
|
36
|
+
: "Only the project owner can change ownership, visibility and sharing. Administrator status does not grant access to private content.";
|
|
37
|
+
var strip = projects.filter(function (project) { return !project.isMate; }).map(function (project) {
|
|
38
|
+
return '<a class="icon-strip-item' + (project.slug === metadata.slug ? ' active' : '') + '" href="/p/' + encodeURIComponent(project.slug) + '/" title="' + escape(project.title || project.project) + '"><span class="icon-strip-name">' + escape((project.title || project.project || project.slug).substring(0, 2)) + '</span></a>';
|
|
39
|
+
}).join("");
|
|
40
|
+
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + escape(metadata.title) + ' · Clay</title>' +
|
|
41
|
+
'<script src="/appearance-bootstrap.js"></script><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/css/project-admin-overview.css"></head><body>' +
|
|
42
|
+
'<div id="layout"><header id="top-bar"><a href="/" class="top-bar-brand-link" aria-label="Clay home"><span class="top-bar-brand-wordmark"></span></a></header>' +
|
|
43
|
+
'<div id="layout-body"><nav id="icon-strip" aria-label="Projects"><div class="icon-strip-projects">' + strip + '</div></nav>' +
|
|
44
|
+
'<main id="main-area" class="project-admin-overview"><div class="project-admin-canvas"><h1>' + escape(metadata.title) + '</h1>' +
|
|
45
|
+
'<div role="tablist" aria-label="Project information"><button type="button" role="tab" aria-selected="true" id="overview-tab" aria-controls="overview-panel">Overview</button><button type="button" role="tab" aria-selected="false" id="access-tab" aria-controls="access-panel">Access</button></div>' +
|
|
46
|
+
'<section id="overview-panel" role="tabpanel" aria-labelledby="overview-tab"><dl><dt>Owner</dt><dd>' + escape(metadata.ownerName) + '</dd><dt>Visibility</dt><dd>' + escape(metadata.visibility) + '</dd><dt>Status</dt><dd>' + (metadata.ownershipRecovery ? 'Ownership needs recovery' : escape(metadata.runtimeHealth)) + '</dd><dt>Your access</dt><dd>Administrative metadata only</dd></dl><p>' + escape(privacyText) + '</p><p class="project-admin-secondary">Resource usage is unavailable for this project.</p></section>' +
|
|
47
|
+
'<section id="access-panel" role="tabpanel" aria-labelledby="access-tab" hidden><h2>Administrative metadata only</h2><p>' + escape(accessText) + '</p></section></div></main></div></div>' +
|
|
48
|
+
'<script type="module" src="/modules/project-admin-overview.js"></script></body></html>';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { metadataFor: metadataFor, page: page };
|
package/lib/project-ask-user.js
CHANGED
|
@@ -25,7 +25,7 @@ function attachAskUser(ctx) {
|
|
|
25
25
|
respond.cancel("Autonomous mode. Make your own decision.");
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
|
-
var questions =
|
|
28
|
+
var questions = ["mcp-import", "product-control"].indexOf(request.source) >= 0 ? request.questions : planningQuestions(boundSession, request.questions);
|
|
29
29
|
var input = { questions: questions };
|
|
30
30
|
if (request.source === 'mcp-import') input.source = 'mcp-import';
|
|
31
31
|
if (!boundSession.pendingAskUser) boundSession.pendingAskUser = {};
|
|
@@ -40,6 +40,8 @@ function attachAskUser(ctx) {
|
|
|
40
40
|
boundSession.pendingAskUser[request.id] = pending;
|
|
41
41
|
if (respond && typeof respond.onSettle === "function") {
|
|
42
42
|
respond.onSettle(function (result) {
|
|
43
|
+
if (request.source === 'product-control' && result.status !== 'submitted') ctx.record(boundSession, { type: 'product_control_question_expired', toolId: request.id, error: 'This exact change review expired or was cancelled. No change was authorized.' });
|
|
44
|
+
if (request.source === 'mate-schedule' && result.status !== 'submitted') ctx.record(boundSession, { type: 'mate_schedule_question_expired', toolId: request.id, error: 'This schedule review expired. Request a new review.' });
|
|
43
45
|
if (request.source === 'mcp-import' && result.status !== 'submitted') ctx.record(boundSession, { type: 'mcp_import_consent_expired', toolId: request.id, source: 'mcp-import', error: 'Import consent expired or was cancelled. No connection was imported.' });
|
|
44
46
|
if (boundSession.pendingAskUser && boundSession.pendingAskUser[request.id] === pending) delete boundSession.pendingAskUser[request.id];
|
|
45
47
|
});
|
|
@@ -48,7 +50,7 @@ function attachAskUser(ctx) {
|
|
|
48
50
|
ctx.record(boundSession, { type: "tool_executing", id: request.id, name: "AskUserQuestion", input: input, source: request.source });
|
|
49
51
|
}
|
|
50
52
|
var router = ctx.getWorkerQuestionRouter && ctx.getWorkerQuestionRouter();
|
|
51
|
-
if (router && request.source !== "mcp-import") router.routeQuestion(boundSession, request, respond, function (answers, driverId) {
|
|
53
|
+
if (router && request.source !== "mcp-import" && request.source !== "mate-schedule" && request.source !== "product-control") router.routeQuestion(boundSession, request, respond, function (answers, driverId) {
|
|
52
54
|
var displayAnswers = {};
|
|
53
55
|
request.questions.forEach(function (question, index) { displayAnswers[index] = answers[question.id].join(", "); });
|
|
54
56
|
ctx.record(boundSession, { type: "ask_user_answered", toolId: request.id, answers: displayAnswers, answeredByDriver: driverId });
|
|
@@ -12,7 +12,7 @@ function unsafeReason(session, isPaired) {
|
|
|
12
12
|
if (session.homeDebatePlanning || session.homeDebatePhase || session.debateSetupMode || session.debateState || session._debate) return "Debate sessions cannot receive project follow-ups.";
|
|
13
13
|
if (session.loop || session.spawn || session.handoff || session.assignment || session.singleTurn || session._pairDelegation || isPaired) return "Orchestrated sessions cannot receive project follow-ups.";
|
|
14
14
|
if (session.dangerouslySkipPermissions || session.permissionMode === "bypassPermissions") return "Full-access sessions cannot receive project follow-ups.";
|
|
15
|
-
if (session.destroying || session.scheduledMessage || session.rateLimitAutoContinuePending || session.taskStopRequested || session._runtimeRefreshRequested || session.pendingPush && session.pendingPush.length) return "Target session is not safely resumable right now.";
|
|
15
|
+
if (session.destroying || session.scheduledMessage || session.rateLimitAutoContinuePending || session.taskStopRequested || session._pendingMessageDrainPaused || session._runtimeRefreshRequested || session.pendingPush && session.pendingPush.length) return "Target session is not safely resumable right now.";
|
|
16
16
|
return "";
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -36,6 +36,7 @@ function attachDelegatedFollowUp(ctx) {
|
|
|
36
36
|
var paired = !!(ctx.isPairedSession && ctx.isPairedSession(session));
|
|
37
37
|
var reason = unsafeReason(session, paired);
|
|
38
38
|
if (reason) throw new Error(reason);
|
|
39
|
+
if (ctx.isPendingQueuePaused && ctx.isPendingQueuePaused(session)) throw new Error("The Driver queue is stopped; explicit owner resume is required.");
|
|
39
40
|
if (!ctx.canResumeVendor(session.vendor)) throw new Error("Target session provider cannot safely resume this conversation.");
|
|
40
41
|
return { session: session, sessionRef: targetSessionRef, title: session.title || "Existing session" };
|
|
41
42
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Reserve a new project directory before running Git or changing ownership.
|
|
2
|
+
// Filesystem identity checks also keep failed attempts from deleting targets
|
|
3
|
+
// replaced by another actor. Hostile OS/root mutation is outside this boundary.
|
|
4
|
+
var fs = require("fs");
|
|
5
|
+
var path = require("path");
|
|
6
|
+
|
|
7
|
+
function canonical(candidate) {
|
|
8
|
+
var current = path.resolve(candidate);
|
|
9
|
+
var missing = [];
|
|
10
|
+
while (true) {
|
|
11
|
+
try { return path.join.apply(path, [fs.realpathSync(current)].concat(missing)); }
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== "ENOENT") throw error;
|
|
14
|
+
// A dangling symlink is an existing object, not a missing directory.
|
|
15
|
+
try { fs.lstatSync(current); throw new Error("A project path contains a dangling symlink."); }
|
|
16
|
+
catch (statError) { if (statError.code !== "ENOENT") throw statError; }
|
|
17
|
+
var parent = path.dirname(current);
|
|
18
|
+
if (parent === current) throw error;
|
|
19
|
+
missing.unshift(path.basename(current));
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inside(root, candidate) {
|
|
26
|
+
var relative = path.relative(root, candidate);
|
|
27
|
+
return relative !== ".." && relative.indexOf(".." + path.sep) !== 0 && !path.isAbsolute(relative);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rejectIntersection(target, projects, userId) {
|
|
31
|
+
(projects || []).forEach(function (project) {
|
|
32
|
+
var root = canonical(project.path);
|
|
33
|
+
if (target === root || inside(target, root) || (project.ownerId !== userId && inside(root, target))) {
|
|
34
|
+
throw new Error("The destination intersects a registered project.");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function claim(options) {
|
|
40
|
+
var slug = options.slug;
|
|
41
|
+
if (!slug || slug === "." || slug === ".." || path.basename(slug) !== slug) throw new Error("Invalid project directory name.");
|
|
42
|
+
var base = canonical(options.baseDir);
|
|
43
|
+
var target = path.join(base, slug);
|
|
44
|
+
rejectIntersection(target, options.projects, options.userId);
|
|
45
|
+
fs.mkdirSync(base, { recursive: true });
|
|
46
|
+
if (fs.realpathSync(options.baseDir) !== base) throw new Error("The project base directory changed.");
|
|
47
|
+
rejectIntersection(target, options.projects, options.userId);
|
|
48
|
+
// EEXIST rejects directories, files, symlinks and concurrent claims alike.
|
|
49
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
50
|
+
var identity = fs.lstatSync(target);
|
|
51
|
+
function assertOwned() {
|
|
52
|
+
var current = fs.lstatSync(target);
|
|
53
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino || fs.realpathSync(base) !== base) {
|
|
54
|
+
throw new Error("The claimed project directory changed.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function cleanup() {
|
|
58
|
+
try { assertOwned(); } catch (error) { return false; }
|
|
59
|
+
fs.rmSync(target, { recursive: true });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return { path: target, assertOwned: assertOwned, cleanup: cleanup };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { claim: claim, canonical: canonical };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shared file-viewer effects. Authority and scope are derived by each caller.
|
|
2
|
+
var fs = require('fs'), path = require('path'), crypto = require('crypto');
|
|
3
|
+
var resolve = require('./project-file-path').resolveFilePath;
|
|
4
|
+
function hash(content) { return crypto.createHash('sha256').update(content).digest('hex'); }
|
|
5
|
+
function attachFileOperations(ctx) {
|
|
6
|
+
function list(scope, requested) {
|
|
7
|
+
var dir = resolve(ctx.cwd, requested || '.', scope);
|
|
8
|
+
var items = scope.identity ? ctx.fsAsUser('list', { dir: dir }, scope.identity) : fs.readdirSync(dir, { withFileTypes: true }).map(function (item) { return { name: item.name, isDir: item.isDirectory() }; });
|
|
9
|
+
return items.filter(function (item) { return !item.isDir || !ctx.IGNORED_DIRS.has(item.name); }).map(function (item) { return { name: item.name, type: item.isDir ? 'dir' : 'file', path: path.relative(ctx.cwd, path.join(dir, item.name)).split(path.sep).join('/') }; });
|
|
10
|
+
}
|
|
11
|
+
function read(scope, requested) {
|
|
12
|
+
var file = resolve(ctx.cwd, requested, scope), identity = scope.identity;
|
|
13
|
+
var stat = identity ? ctx.fsAsUser('stat', { file: file }, identity) : fs.statSync(file);
|
|
14
|
+
if (stat.size > ctx.FS_MAX_SIZE) return { binary: true, size: stat.size, error: 'File too large (' + (stat.size / 1024 / 1024).toFixed(1) + ' MB)' };
|
|
15
|
+
var ext = path.extname(file).toLowerCase();
|
|
16
|
+
if (ctx.BINARY_EXTS.has(ext)) {
|
|
17
|
+
var binary = { binary: true, size: stat.size };
|
|
18
|
+
if (ctx.IMAGE_EXTS.has(ext)) binary.imageUrl = 'api/file?path=' + encodeURIComponent(requested);
|
|
19
|
+
return binary;
|
|
20
|
+
}
|
|
21
|
+
return { content: identity ? ctx.fsAsUser('read', { file: file, readContent: true }, identity).content : fs.readFileSync(file, 'utf8'), size: stat.size };
|
|
22
|
+
}
|
|
23
|
+
function snapshot(scope, requested) {
|
|
24
|
+
var file = resolve(ctx.cwd, requested, scope), stat = fs.lstatSync(file);
|
|
25
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 65536) throw new Error('Select a regular, singly linked text file of at most 64 KB.');
|
|
26
|
+
var result = read(scope, requested);
|
|
27
|
+
if (result.binary || typeof result.content !== 'string' || result.content.indexOf('\0') >= 0) throw new Error('Binary files are unavailable for canonical editing.');
|
|
28
|
+
return { dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid, size: stat.size, mtime: stat.mtimeMs, hash: hash(result.content) };
|
|
29
|
+
}
|
|
30
|
+
function write(scope, requested, content, control) {
|
|
31
|
+
var file = resolve(ctx.cwd, requested, scope), attempted = false;
|
|
32
|
+
if (!control) {
|
|
33
|
+
if (scope.identity) ctx.fsAsUser('write', { file: file, content: content }, scope.identity);
|
|
34
|
+
else fs.writeFileSync(file, content, 'utf8');
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
if (typeof fs.constants.O_NOFOLLOW !== 'number') throw new Error('Canonical editing requires no-follow file descriptors on this platform.');
|
|
38
|
+
control.revalidate();
|
|
39
|
+
if (JSON.stringify(snapshot(scope, requested)) !== JSON.stringify(control.expected)) throw new Error('The exact file revision changed.');
|
|
40
|
+
try {
|
|
41
|
+
attempted = true;
|
|
42
|
+
if (scope.identity) ctx.fsAsUser('write_exact', { file: file, content: content, expected: control.expected, root: fs.realpathSync(ctx.cwd) }, scope.identity);
|
|
43
|
+
else {
|
|
44
|
+
var fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
|
|
45
|
+
try {
|
|
46
|
+
var stat = fs.fstatSync(fd), expected = control.expected;
|
|
47
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== expected.dev || stat.ino !== expected.ino || stat.mode !== expected.mode || stat.uid !== expected.uid || stat.gid !== expected.gid || stat.size !== expected.size || stat.mtimeMs !== expected.mtime || hash(fs.readFileSync(fd, 'utf8')) !== expected.hash) throw new Error('The open file revision changed.');
|
|
48
|
+
control.revalidate();
|
|
49
|
+
fs.ftruncateSync(fd, 0);
|
|
50
|
+
var bytes = Buffer.from(content, 'utf8'), offset = 0;
|
|
51
|
+
while (offset < bytes.length) { var written = fs.writeSync(fd, bytes, offset, bytes.length - offset, offset); if (!written) throw new Error('File write made no progress.'); offset += written; }
|
|
52
|
+
fs.fsyncSync(fd);
|
|
53
|
+
} finally { fs.closeSync(fd); }
|
|
54
|
+
}
|
|
55
|
+
control.revalidate();
|
|
56
|
+
return { status: 'completed', persisted: true, path: requested };
|
|
57
|
+
} catch (error) { error.productControlResult = { persisted: attempted ? 'unknown' : false, retrySafe: false, error: error.message }; throw error; }
|
|
58
|
+
}
|
|
59
|
+
return { list: list, read: read, write: write, snapshot: snapshot };
|
|
60
|
+
}
|
|
61
|
+
module.exports = { attachFileOperations: attachFileOperations };
|