@velarscript/desktop 0.10.1

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.
Binary file
@@ -0,0 +1,203 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ private let frameWrite: UInt8 = 1
5
+ private let frameResize: UInt8 = 2
6
+ private let frameClose: UInt8 = 3
7
+ private let maximumFrameBytes = 1024 * 1024
8
+ private var terminationSignal: Int32 = 0
9
+
10
+ private func fail(_ message: String) -> Never {
11
+ FileHandle.standardError.write(Data(("Velar terminal host: \(message)\n").utf8))
12
+ Darwin.exit(1)
13
+ }
14
+
15
+ private func integer(_ value: String, minimum: Int, maximum: Int, name: String) -> Int {
16
+ guard let parsed = Int(value), parsed >= minimum, parsed <= maximum else {
17
+ fail("\(name) must be an integer from \(minimum) through \(maximum)")
18
+ }
19
+ return parsed
20
+ }
21
+
22
+ private func loginShell() -> String {
23
+ var metadata = stat()
24
+ guard stat("/etc/shells", &metadata) == 0, metadata.st_size >= 1, metadata.st_size <= 64 * 1024,
25
+ let source = try? String(contentsOfFile: "/etc/shells", encoding: .utf8) else {
26
+ fail("the trusted login-shell registry is unavailable")
27
+ }
28
+ let approved = Set(source.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
29
+ .map(String.init)
30
+ .filter({ $0.hasPrefix("/") && !$0.contains("#") }))
31
+ if let account = getpwuid(getuid()), let shell = account.pointee.pw_shell {
32
+ let value = String(cString: shell)
33
+ if approved.contains(value), value.utf8.count <= 4096, access(value, X_OK) == 0 { return value }
34
+ }
35
+ guard approved.contains("/bin/zsh"), access("/bin/zsh", X_OK) == 0 else { fail("no trusted login shell is available") }
36
+ return "/bin/zsh"
37
+ }
38
+
39
+ private func writeAll(_ descriptor: Int32, _ data: Data) -> Bool {
40
+ return data.withUnsafeBytes { bytes in
41
+ guard let start = bytes.baseAddress else { return true }
42
+ var offset = 0
43
+ while offset < data.count {
44
+ let count = Darwin.write(descriptor, start.advanced(by: offset), data.count - offset)
45
+ if count > 0 { offset += count; continue }
46
+ if count == -1 && errno == EINTR { continue }
47
+ return false
48
+ }
49
+ return true
50
+ }
51
+ }
52
+
53
+ private func signalShell(_ pid: pid_t, _ signal: Int32) {
54
+ if Darwin.kill(-pid, signal) == -1 { _ = Darwin.kill(pid, signal) }
55
+ }
56
+
57
+ private func childExitCode(_ status: Int32) -> Int32 {
58
+ let signal = status & 0x7f
59
+ if signal == 0 { return (status >> 8) & 0xff }
60
+ if signal != 0x7f { return min(255, 128 + signal) }
61
+ return 1
62
+ }
63
+
64
+ private func decodeUInt32(_ data: Data, at offset: Int) -> UInt32 {
65
+ return data[offset..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
66
+ }
67
+
68
+ private func run() -> Never {
69
+ guard CommandLine.arguments.count == 4 else { fail("expected project directory, columns, and rows") }
70
+ let directory = CommandLine.arguments[1]
71
+ guard directory.hasPrefix("/"), directory.utf8.count <= 4096, !directory.contains("\0") else {
72
+ fail("project directory must be a bounded absolute path")
73
+ }
74
+ let columns = integer(CommandLine.arguments[2], minimum: 20, maximum: 1000, name: "columns")
75
+ let rows = integer(CommandLine.arguments[3], minimum: 5, maximum: 1000, name: "rows")
76
+ let shell = loginShell()
77
+ var window = winsize(ws_row: UInt16(rows), ws_col: UInt16(columns), ws_xpixel: 0, ws_ypixel: 0)
78
+ var master: Int32 = -1
79
+ let child = forkpty(&master, nil, nil, &window)
80
+ guard child >= 0 else { fail("forkpty failed: \(String(cString: strerror(errno)))") }
81
+
82
+ if child == 0 {
83
+ _ = Darwin.close(3)
84
+ guard chdir(directory) == 0 else { _exit(126) }
85
+ let name = URL(fileURLWithPath: shell).lastPathComponent
86
+ let argument = strdup("-\(name)")
87
+ let path = strdup(shell)
88
+ guard let argument, let path else { _exit(126) }
89
+ var arguments: [UnsafeMutablePointer<CChar>?] = [argument, nil]
90
+ arguments.withUnsafeMutableBufferPointer { buffer in
91
+ _ = execv(path, buffer.baseAddress)
92
+ }
93
+ _exit(126)
94
+ }
95
+
96
+ let metadata = Data("{\"protocolVersion\":1,\"pid\":\(child)}\n".utf8)
97
+ guard writeAll(3, metadata) else {
98
+ signalShell(child, SIGKILL)
99
+ _ = waitpid(child, nil, 0)
100
+ fail("could not publish shell ownership")
101
+ }
102
+ _ = Darwin.close(3)
103
+ signal(SIGTERM) { value in terminationSignal = value }
104
+ signal(SIGHUP) { value in terminationSignal = value }
105
+ signal(SIGINT) { value in terminationSignal = value }
106
+
107
+ var input = Data()
108
+ var shellStatus: Int32 = 0
109
+ var shellSettled = false
110
+ var masterClosed = false
111
+ var closeRequested = false
112
+ var bytes = [UInt8](repeating: 0, count: 64 * 1024)
113
+
114
+ while !shellSettled || !masterClosed {
115
+ if terminationSignal != 0 && !closeRequested {
116
+ closeRequested = true
117
+ signalShell(child, SIGTERM)
118
+ }
119
+ if !shellSettled {
120
+ let result = waitpid(child, &shellStatus, WNOHANG)
121
+ if result == child { shellSettled = true }
122
+ else if result == -1 && errno != EINTR { shellSettled = true; shellStatus = 1 << 8 }
123
+ }
124
+
125
+ var descriptors = [
126
+ pollfd(fd: masterClosed ? -1 : master, events: Int16(POLLIN | POLLHUP), revents: 0),
127
+ pollfd(fd: closeRequested ? -1 : STDIN_FILENO, events: Int16(POLLIN | POLLHUP), revents: 0),
128
+ ]
129
+ let polled = poll(&descriptors, nfds_t(descriptors.count), shellSettled ? 25 : 100)
130
+ if polled == -1 && errno != EINTR {
131
+ signalShell(child, SIGKILL)
132
+ if !shellSettled { _ = waitpid(child, &shellStatus, 0); shellSettled = true }
133
+ fail("poll failed: \(String(cString: strerror(errno)))")
134
+ }
135
+
136
+ if !masterClosed && descriptors[0].revents & Int16(POLLIN | POLLHUP | POLLERR) != 0 {
137
+ let count = Darwin.read(master, &bytes, bytes.count)
138
+ if count > 0 {
139
+ if !writeAll(STDOUT_FILENO, Data(bytes[0..<count])) {
140
+ signalShell(child, SIGKILL)
141
+ if !shellSettled { _ = waitpid(child, &shellStatus, 0); shellSettled = true }
142
+ masterClosed = true
143
+ }
144
+ } else if count == 0 || count == -1 && (errno == EIO || errno != EINTR) {
145
+ _ = Darwin.close(master)
146
+ masterClosed = true
147
+ }
148
+ }
149
+
150
+ if !closeRequested && descriptors[1].revents & Int16(POLLIN | POLLHUP | POLLERR) != 0 {
151
+ let count = Darwin.read(STDIN_FILENO, &bytes, bytes.count)
152
+ if count > 0 { input.append(contentsOf: bytes[0..<count]) }
153
+ else if count == 0 || count == -1 && errno != EINTR {
154
+ closeRequested = true
155
+ signalShell(child, SIGHUP)
156
+ }
157
+ }
158
+
159
+ while !closeRequested && input.count >= 5 {
160
+ let kind = input[0]
161
+ let length = Int(decodeUInt32(input, at: 1))
162
+ if length > maximumFrameBytes {
163
+ closeRequested = true
164
+ signalShell(child, SIGKILL)
165
+ break
166
+ }
167
+ if input.count < 5 + length { break }
168
+ let payload = Data(input[5..<5 + length])
169
+ input.removeSubrange(0..<5 + length)
170
+ if kind == frameWrite {
171
+ if length == 0 || !writeAll(master, payload) {
172
+ closeRequested = true
173
+ signalShell(child, SIGKILL)
174
+ }
175
+ } else if kind == frameResize && length == 8 {
176
+ let nextColumns = decodeUInt32(payload, at: 0)
177
+ let nextRows = decodeUInt32(payload, at: 4)
178
+ if nextColumns < 20 || nextColumns > 1000 || nextRows < 5 || nextRows > 1000 {
179
+ closeRequested = true
180
+ signalShell(child, SIGKILL)
181
+ } else {
182
+ var size = winsize(ws_row: UInt16(nextRows), ws_col: UInt16(nextColumns), ws_xpixel: 0, ws_ypixel: 0)
183
+ if ioctl(master, TIOCSWINSZ, &size) == -1 {
184
+ closeRequested = true
185
+ signalShell(child, SIGKILL)
186
+ }
187
+ }
188
+ } else if kind == frameClose && length == 0 {
189
+ closeRequested = true
190
+ signalShell(child, SIGHUP)
191
+ } else {
192
+ closeRequested = true
193
+ signalShell(child, SIGKILL)
194
+ }
195
+ }
196
+
197
+ if shellSettled && masterClosed { break }
198
+ }
199
+ if !shellSettled { _ = waitpid(child, &shellStatus, 0) }
200
+ Darwin.exit(childExitCode(shellStatus))
201
+ }
202
+
203
+ run()
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+
5
+ import { FileProjectChangeFeed } from "@velaros-ai/project/changes";
6
+ import { createProjectKernel } from "@velaros-ai/project/runtime";
7
+ import { createProjectTransactionController } from "@velaros-ai/project/transaction-controller";
8
+
9
+ const MAX_PROJECT_CHANGE_RECORD_BYTES = 16 * 1024 * 1024;
10
+ const MAX_PROJECT_CHANGE_PAGE_BYTES = 32 * 1024 * 1024;
11
+ const MAX_PROJECT_CHANGE_ITEMS = 1_000;
12
+ const projectChangeLifecycles = new Set([
13
+ "prepared",
14
+ "amended",
15
+ "validated",
16
+ "validation_failed",
17
+ "applied",
18
+ "rolled_back",
19
+ "discarded",
20
+ ]);
21
+ const riskLevels = new Set(["low", "medium", "high"]);
22
+
23
+ function record(value, label) {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
25
+ return value;
26
+ }
27
+
28
+ function text(value, label, maximumBytes, allowEmpty = false) {
29
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.includes("\0") || Buffer.byteLength(value, "utf8") > maximumBytes) {
30
+ throw new TypeError(`${label} must be bounded text`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function optionalText(value, label, maximumBytes) {
36
+ return value === undefined ? null : text(value, label, maximumBytes);
37
+ }
38
+
39
+ function safeInteger(value, label, minimum = 0) {
40
+ if (!Number.isSafeInteger(value) || value < minimum) throw new TypeError(`${label} must be a safe integer`);
41
+ return value;
42
+ }
43
+
44
+ function list(value, label) {
45
+ if (!Array.isArray(value) || value.length > MAX_PROJECT_CHANGE_ITEMS) throw new TypeError(`${label} must be a bounded list`);
46
+ return value;
47
+ }
48
+
49
+ function intentView(value) {
50
+ value = record(value, "Project change intent");
51
+ const operation = record(value.operation, "Project change operation");
52
+ return Object.freeze({
53
+ type: text(operation.type, "Project change operation type", 128),
54
+ path: optionalText(operation.path, "Project change operation path", 4096),
55
+ from: optionalText(operation.from, "Project change operation source path", 4096),
56
+ to: optionalText(operation.to, "Project change operation target path", 4096),
57
+ targetId: optionalText(value.targetId, "Project change target id", 512),
58
+ reason: optionalText(value.reason, "Project change intent reason", 64 * 1024),
59
+ });
60
+ }
61
+
62
+ function patchView(value) {
63
+ value = record(value, "Project change patch");
64
+ if (!riskLevels.has(value.risk)) throw new TypeError("Project change patch risk is invalid");
65
+ return Object.freeze({
66
+ patchId: text(value.patchId, "Project change patch id", 512),
67
+ strategyId: text(value.strategyId, "Project change strategy id", 512),
68
+ path: text(value.path, "Project change patch path", 4096),
69
+ baseRevision: optionalText(value.baseRevision, "Project change patch base revision", 512),
70
+ diff: text(value.diff, "Project change patch diff", MAX_PROJECT_CHANGE_RECORD_BYTES, true),
71
+ changedLines: safeInteger(value.changedLines, "Project change patch changed lines"),
72
+ risk: value.risk,
73
+ operation: optionalText(value.operation, "Project change patch operation", 128),
74
+ });
75
+ }
76
+
77
+ function revisionView(value) {
78
+ value = record(value, "Project change revision");
79
+ return Object.freeze({
80
+ path: text(value.path, "Project change revision path", 4096),
81
+ before: optionalText(value.before, "Project change prior revision", 512),
82
+ after: optionalText(value.after, "Project change next revision", 512),
83
+ });
84
+ }
85
+
86
+ export function desktopProjectChangeView(value) {
87
+ value = record(value, "Project change");
88
+ if (!projectChangeLifecycles.has(value.lifecycle)) throw new TypeError("Project change lifecycle is invalid");
89
+ if (!riskLevels.has(value.risk)) throw new TypeError("Project change risk is invalid");
90
+ const output = Object.freeze({
91
+ transactionId: text(value.transactionId, "Project change transaction id", 512),
92
+ sequence: safeInteger(value.sequence, "Project change sequence", 1),
93
+ lifecycle: value.lifecycle,
94
+ reason: optionalText(value.reason, "Project change reason", 64 * 1024),
95
+ intents: Object.freeze(list(value.intents, "Project change intents").map(intentView)),
96
+ patches: Object.freeze(list(value.patches, "Project change patches").map(patchView)),
97
+ changedFiles: Object.freeze(list(value.changedFiles, "Project change files")
98
+ .map((path) => text(path, "Project change file path", 4096))),
99
+ diff: text(value.diff, "Project change diff", MAX_PROJECT_CHANGE_RECORD_BYTES, true),
100
+ changedLines: safeInteger(value.changedLines, "Project change changed lines"),
101
+ risk: value.risk,
102
+ revisions: Object.freeze(list(value.revisions, "Project change revisions").map(revisionView)),
103
+ createdAt: safeInteger(value.createdAt, "Project change creation time"),
104
+ updatedAt: safeInteger(value.updatedAt, "Project change update time"),
105
+ appliedAt: value.appliedAt === undefined ? null : safeInteger(value.appliedAt, "Project change apply time"),
106
+ });
107
+ if (Buffer.byteLength(JSON.stringify(output), "utf8") > MAX_PROJECT_CHANGE_RECORD_BYTES) {
108
+ throw new RangeError("Project change record exceeds 16 MiB");
109
+ }
110
+ return output;
111
+ }
112
+
113
+ export function desktopProjectChangePage(values, requestedLimit) {
114
+ const limit = safeInteger(requestedLimit, "Project change page limit", 1);
115
+ if (limit > 100) throw new RangeError("Project change page limit cannot exceed 100");
116
+ const changes = [];
117
+ let bytes = 0;
118
+ let truncated = false;
119
+ for (const value of values) {
120
+ if (changes.length >= limit) { truncated = true; break; }
121
+ const change = desktopProjectChangeView(value);
122
+ const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8");
123
+ if (bytes + changeBytes > MAX_PROJECT_CHANGE_PAGE_BYTES) { truncated = true; break; }
124
+ changes.push(change);
125
+ bytes += changeBytes;
126
+ }
127
+ return Object.freeze({ changes: Object.freeze(changes), truncated });
128
+ }
129
+
130
+ /**
131
+ * One host-private owner per canonical project root. The renderer never sees
132
+ * the root-derived key, state path, feed path, policy or underlying kernel.
133
+ */
134
+ export async function createDesktopProjectTransactionOwner(projectRoot, appDataRoot) {
135
+ const key = createHash("sha256").update(projectRoot).digest("hex");
136
+ const directory = resolve(appDataRoot, "project-transactions", key);
137
+ await mkdir(directory, { recursive: true, mode: 0o700 });
138
+ const feed = new FileProjectChangeFeed({ path: resolve(directory, "changes.jsonl") });
139
+ try {
140
+ const project = await createProjectKernel({
141
+ root: projectRoot,
142
+ changeFeed: feed,
143
+ transactionStatePath: resolve(directory, "transactions.json"),
144
+ // Desktop's project grant is the outer write authority. The finite
145
+ // apply/rollback call is already explicit and cannot choose an operation.
146
+ corePolicy: { approval: { requireForHighRiskPatch: false } },
147
+ });
148
+ return {
149
+ root: projectRoot,
150
+ controller: createProjectTransactionController(project),
151
+ close() { feed.close(); },
152
+ };
153
+ } catch (error) {
154
+ feed.close();
155
+ throw error;
156
+ }
157
+ }