@solarisdk/core 0.4.2 → 0.4.3
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/dist/desktop.js +23 -4
- package/dist/handle.d.ts +18 -3
- package/dist/handle.js +103 -43
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/desktop.js
CHANGED
|
@@ -46,11 +46,19 @@ export class Desktop extends SessionHandle {
|
|
|
46
46
|
stream: opts.stream,
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
execStream(cmd, onChunk, opts = {}) {
|
|
50
|
-
|
|
49
|
+
async execStream(cmd, onChunk, opts = {}) {
|
|
50
|
+
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
|
51
|
+
const result = await this.rpcStream("exec", { cmd, args: opts.args ?? [], cwd: opts.cwd, timeoutMs: opts.timeoutMs, stream: true }, (c) => {
|
|
51
52
|
const bytes = c.data ? base64ToBytes(c.data) : new Uint8Array();
|
|
52
|
-
|
|
53
|
+
const dec = decoders[c.stream] ?? decoders.stdout;
|
|
54
|
+
onChunk({ stream: c.stream, text: dec.decode(bytes, { stream: true }), bytes });
|
|
53
55
|
});
|
|
56
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
57
|
+
const rest = decoders[stream].decode();
|
|
58
|
+
if (rest)
|
|
59
|
+
onChunk({ stream, text: rest, bytes: new Uint8Array() });
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
54
62
|
}
|
|
55
63
|
fs = {
|
|
56
64
|
read: (path) => this.files.read(path),
|
|
@@ -126,7 +134,18 @@ export class Desktop extends SessionHandle {
|
|
|
126
134
|
process = {
|
|
127
135
|
list: async () => {
|
|
128
136
|
const r = await this.guiCall("process.list", {});
|
|
129
|
-
return r.processes
|
|
137
|
+
return (r.processes ?? []).map((p) => {
|
|
138
|
+
const info = {
|
|
139
|
+
pid: Number(p.pid),
|
|
140
|
+
name: String(p.name ?? p.comm ?? ""),
|
|
141
|
+
};
|
|
142
|
+
const cmd = p.cmd ?? p.cmdline;
|
|
143
|
+
if (typeof cmd === "string")
|
|
144
|
+
info.cmd = cmd;
|
|
145
|
+
if (typeof p.state === "string")
|
|
146
|
+
info.state = p.state;
|
|
147
|
+
return info;
|
|
148
|
+
});
|
|
130
149
|
},
|
|
131
150
|
kill: (pid) => this.guiCall("process.kill", { pid }).then(() => undefined),
|
|
132
151
|
start: async (cmd, opts = {}) => {
|
package/dist/handle.d.ts
CHANGED
|
@@ -58,12 +58,18 @@ export interface SessionConfig extends ControlChannelConfig {
|
|
|
58
58
|
export declare const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
|
|
59
59
|
/** Cap on PTY output held before the first onData() subscriber (bytes, total). */
|
|
60
60
|
export declare const PTY_EARLY_BUFFER_MAX: number;
|
|
61
|
+
export interface SessionHandle {
|
|
62
|
+
/** `await using sbx = await client.create(...)` releases the machine (`kill()`) when the scope ends. */
|
|
63
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
64
|
+
}
|
|
61
65
|
export declare class SessionHandle {
|
|
62
66
|
/** The session id (sandboxId / sessionId). */
|
|
63
67
|
readonly id: string;
|
|
64
68
|
readonly controlUrl: string;
|
|
65
69
|
readonly expiresAt: string;
|
|
66
70
|
protected readonly channel: ControlChannel;
|
|
71
|
+
/** Set once the gateway has refused a command stream because this sandbox is `shared`. */
|
|
72
|
+
private sharedContext;
|
|
67
73
|
protected readonly hooks: SessionHooks;
|
|
68
74
|
/** Set by {@link kill} so {@link close} doesn't warn about a machine we
|
|
69
75
|
* have just released. */
|
|
@@ -198,10 +204,19 @@ export declare class SessionHandle {
|
|
|
198
204
|
};
|
|
199
205
|
/** Run `git <args>` in the guest via the command RPC (no shell). */
|
|
200
206
|
private runGit;
|
|
201
|
-
/** Run `git <args>`; throw with the stderr on a non-zero exit. */
|
|
207
|
+
/** Run `git <args>`; throw with the stderr on a non-zero exit. A password passed in `secret` is masked in the message. */
|
|
202
208
|
private mustGit;
|
|
203
|
-
/**
|
|
204
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Credentials for ONE git command, for a private http(s) remote at `remoteUrl`. They go to the guest as environment
|
|
211
|
+
* variables of that command and git reads them back through an inline credential helper, so they are never in the
|
|
212
|
+
* clone URL (and so not in `.git/config`, a snapshot or a template), never in argv (`/proc/<pid>/cmdline`), and never
|
|
213
|
+
* in a config file: the `-c` options last for this command only and the helper script holds no secret.
|
|
214
|
+
*
|
|
215
|
+
* The helper is bound to the remote's origin (`credential.<scheme>://<host>.helper`), so git never hands the password
|
|
216
|
+
* to another host: a redirect to a different host, or a downgrade to http, gets nothing. Returns undefined when
|
|
217
|
+
* there are no credentials, or the remote is not http(s) (ssh remotes do not use them).
|
|
218
|
+
*/
|
|
219
|
+
private gitAuth;
|
|
205
220
|
private gitClone;
|
|
206
221
|
private gitStatus;
|
|
207
222
|
private gitAdd;
|
package/dist/handle.js
CHANGED
|
@@ -23,11 +23,23 @@ function trackUnreleased(token, id) {
|
|
|
23
23
|
}
|
|
24
24
|
const MIN_REST_TIMEOUT_MS = 1000;
|
|
25
25
|
export const PTY_EARLY_BUFFER_MAX = 1024 * 1024;
|
|
26
|
+
const GIT_CREDENTIAL_HELPER = '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$SOLARI_GIT_USERNAME" "$SOLARI_GIT_PASSWORD"; }; f';
|
|
27
|
+
function gitSubcommand(args) {
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (args[i] === "-c")
|
|
30
|
+
i += 2;
|
|
31
|
+
return args[i] ?? "";
|
|
32
|
+
}
|
|
33
|
+
function rejectOptionLike(what, v) {
|
|
34
|
+
if (v.startsWith("-"))
|
|
35
|
+
throw new Error(`git ${what} must not start with "-": ${JSON.stringify(v)}`);
|
|
36
|
+
}
|
|
26
37
|
export class SessionHandle {
|
|
27
38
|
id;
|
|
28
39
|
controlUrl;
|
|
29
40
|
expiresAt;
|
|
30
41
|
channel;
|
|
42
|
+
sharedContext = false;
|
|
31
43
|
hooks;
|
|
32
44
|
killed = false;
|
|
33
45
|
releaseToken = {};
|
|
@@ -120,17 +132,26 @@ export class SessionHandle {
|
|
|
120
132
|
opts.onStderr?.(c.data);
|
|
121
133
|
});
|
|
122
134
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const data = f.base64 ? new TextDecoder().decode(base64ToBytes(f.base64)) : "";
|
|
126
|
-
const chunk = { stream, data };
|
|
135
|
+
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
|
136
|
+
const emit = (chunk) => {
|
|
127
137
|
if (dataCbs.length === 0)
|
|
128
138
|
buffered.push(chunk);
|
|
129
139
|
else
|
|
130
140
|
for (const cb of dataCbs)
|
|
131
141
|
cb(chunk);
|
|
142
|
+
};
|
|
143
|
+
this.channel.onFrame("cmd.data", cmdId, (f) => {
|
|
144
|
+
const stream = f.stream ?? "stdout";
|
|
145
|
+
const dec = decoders[stream] ?? decoders.stdout;
|
|
146
|
+
const data = f.base64 ? dec.decode(base64ToBytes(f.base64), { stream: true }) : "";
|
|
147
|
+
emit({ stream, data });
|
|
132
148
|
});
|
|
133
149
|
this.channel.onFrame("cmd.exit", cmdId, (f) => {
|
|
150
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
151
|
+
const rest = decoders[stream].decode();
|
|
152
|
+
if (rest)
|
|
153
|
+
emit({ stream, data: rest });
|
|
154
|
+
}
|
|
134
155
|
this.channel.offFrame("cmd.data", cmdId);
|
|
135
156
|
this.channel.offFrame("cmd.exit", cmdId);
|
|
136
157
|
this.channel.offStreamClose(cmdId);
|
|
@@ -172,23 +193,21 @@ export class SessionHandle {
|
|
|
172
193
|
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
173
194
|
throw new SolariError(`Solari: timeoutMs must be a non-negative number of milliseconds, got ${String(timeoutMs)}`);
|
|
174
195
|
}
|
|
175
|
-
|
|
196
|
+
const restEligible = !!this.hooks.exec &&
|
|
176
197
|
!opts.background &&
|
|
177
198
|
!opts.onStdout &&
|
|
178
199
|
!opts.onStderr &&
|
|
179
200
|
!opts.env &&
|
|
180
201
|
!opts.user &&
|
|
181
202
|
timeoutMs >= MIN_REST_TIMEOUT_MS &&
|
|
182
|
-
timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS
|
|
183
|
-
|
|
203
|
+
timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS;
|
|
204
|
+
const viaRest = async () => {
|
|
205
|
+
const r = await this.hooks.exec(this.id, { cmd, args: opts.args, cwd: opts.cwd, timeoutMs });
|
|
206
|
+
return { ...r, timedOut: r.timedOut === true };
|
|
207
|
+
};
|
|
208
|
+
if (restEligible && (!this.channel.connected || this.sharedContext)) {
|
|
184
209
|
try {
|
|
185
|
-
|
|
186
|
-
cmd,
|
|
187
|
-
args: opts.args,
|
|
188
|
-
cwd: opts.cwd,
|
|
189
|
-
timeoutMs,
|
|
190
|
-
});
|
|
191
|
-
return { ...r, timedOut: r.timedOut === true };
|
|
210
|
+
return await viaRest();
|
|
192
211
|
}
|
|
193
212
|
catch (err) {
|
|
194
213
|
if (!isRouteUnavailable(err))
|
|
@@ -196,7 +215,24 @@ export class SessionHandle {
|
|
|
196
215
|
}
|
|
197
216
|
}
|
|
198
217
|
const acc = { stdout: "", stderr: "" };
|
|
199
|
-
|
|
218
|
+
let handle;
|
|
219
|
+
try {
|
|
220
|
+
handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (!isSharedRefusal(err))
|
|
224
|
+
throw err;
|
|
225
|
+
this.sharedContext = true;
|
|
226
|
+
if (restEligible) {
|
|
227
|
+
try {
|
|
228
|
+
return await viaRest();
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
throw normalizeExecError(e, timeoutMs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
200
236
|
if (opts.background) {
|
|
201
237
|
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
|
202
238
|
}
|
|
@@ -378,42 +414,61 @@ export class SessionHandle {
|
|
|
378
414
|
branches: (cwd) => this.gitBranches(cwd),
|
|
379
415
|
log: (opts = {}) => this.gitLog(opts),
|
|
380
416
|
};
|
|
381
|
-
async runGit(args, cwd) {
|
|
382
|
-
return this.runCommand("git", { args, ...(cwd ? { cwd } : {}) });
|
|
417
|
+
async runGit(args, cwd, env) {
|
|
418
|
+
return this.runCommand("git", { args, ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) });
|
|
383
419
|
}
|
|
384
|
-
async mustGit(args, cwd) {
|
|
385
|
-
const r = await this.runGit(args, cwd);
|
|
420
|
+
async mustGit(args, cwd, env, secret) {
|
|
421
|
+
const r = await this.runGit(args, cwd, env);
|
|
386
422
|
if (r.exitCode !== 0) {
|
|
387
|
-
|
|
388
|
-
|
|
423
|
+
let detail = (r.stderr || r.stdout || "").trim();
|
|
424
|
+
if (secret)
|
|
425
|
+
detail = detail.split(secret).join("***");
|
|
426
|
+
throw new Error(`git ${gitSubcommand(args)} failed (exit ${r.exitCode}): ${detail}`);
|
|
389
427
|
}
|
|
390
428
|
return r.stdout;
|
|
391
429
|
}
|
|
392
|
-
|
|
430
|
+
gitAuth(remoteUrl, username, password) {
|
|
393
431
|
if (!username && !password)
|
|
394
|
-
return
|
|
432
|
+
return undefined;
|
|
433
|
+
for (const v of [username, password]) {
|
|
434
|
+
if (v !== undefined && /[\r\n\0]/.test(v))
|
|
435
|
+
throw new Error("git credentials must not contain line breaks or NUL");
|
|
436
|
+
}
|
|
437
|
+
let origin;
|
|
395
438
|
try {
|
|
396
|
-
const u = new URL(
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
u.password = encodeURIComponent(password);
|
|
401
|
-
return u.toString();
|
|
439
|
+
const u = new URL(remoteUrl);
|
|
440
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
441
|
+
return undefined;
|
|
442
|
+
origin = `${u.protocol}//${u.host}`;
|
|
402
443
|
}
|
|
403
444
|
catch {
|
|
404
|
-
return
|
|
445
|
+
return undefined;
|
|
405
446
|
}
|
|
447
|
+
if (/[=\s]/.test(origin))
|
|
448
|
+
return undefined;
|
|
449
|
+
return {
|
|
450
|
+
cfg: [
|
|
451
|
+
"-c", "credential.helper=",
|
|
452
|
+
"-c", `credential.${origin}.helper=${GIT_CREDENTIAL_HELPER}`,
|
|
453
|
+
],
|
|
454
|
+
env: {
|
|
455
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
456
|
+
SOLARI_GIT_USERNAME: username ?? "",
|
|
457
|
+
SOLARI_GIT_PASSWORD: password ?? "",
|
|
458
|
+
},
|
|
459
|
+
};
|
|
406
460
|
}
|
|
407
461
|
async gitClone(url, opts) {
|
|
408
|
-
const
|
|
462
|
+
const auth = this.gitAuth(url, opts.username, opts.password);
|
|
463
|
+
const args = [...(auth?.cfg ?? []), "clone"];
|
|
409
464
|
if (opts.depth && opts.depth > 0)
|
|
410
465
|
args.push("--depth", String(opts.depth));
|
|
411
466
|
if (opts.branch)
|
|
412
467
|
args.push("--branch", opts.branch);
|
|
413
|
-
args.push(
|
|
468
|
+
args.push("--", url);
|
|
414
469
|
if (opts.path)
|
|
415
470
|
args.push(opts.path);
|
|
416
|
-
await this.mustGit(args, opts.cwd);
|
|
471
|
+
await this.mustGit(args, opts.cwd, auth?.env, opts.password);
|
|
417
472
|
}
|
|
418
473
|
async gitStatus(cwd) {
|
|
419
474
|
const out = await this.mustGit(["status", "--porcelain=v1", "--branch"], cwd);
|
|
@@ -488,25 +543,25 @@ export class SessionHandle {
|
|
|
488
543
|
}
|
|
489
544
|
async gitPushPull(op, opts) {
|
|
490
545
|
const remote = opts.remote || "origin";
|
|
491
|
-
|
|
546
|
+
rejectOptionLike("remote", remote);
|
|
547
|
+
if (opts.branch)
|
|
548
|
+
rejectOptionLike("branch", opts.branch);
|
|
549
|
+
let auth;
|
|
492
550
|
if (opts.username || opts.password) {
|
|
493
|
-
const plain = (await this.
|
|
494
|
-
|
|
495
|
-
const authed = this.authUrl(plain, opts.username, opts.password);
|
|
496
|
-
if (authed !== plain)
|
|
497
|
-
cfg.push("-c", `url.${authed}.insteadOf=${plain}`);
|
|
498
|
-
}
|
|
551
|
+
const plain = (await this.mustGit(["remote", "get-url", "--", remote], opts.cwd)).trim();
|
|
552
|
+
auth = this.gitAuth(plain, opts.username, opts.password);
|
|
499
553
|
}
|
|
500
|
-
const args = [...cfg, op, remote];
|
|
554
|
+
const args = [...(auth?.cfg ?? []), op, "--", remote];
|
|
501
555
|
if (opts.branch)
|
|
502
556
|
args.push(opts.branch);
|
|
503
|
-
await this.mustGit(args, opts.cwd);
|
|
557
|
+
await this.mustGit(args, opts.cwd, auth?.env, opts.password);
|
|
504
558
|
}
|
|
505
559
|
async gitCheckout(ref, opts) {
|
|
560
|
+
rejectOptionLike("ref", ref);
|
|
506
561
|
const args = ["checkout"];
|
|
507
562
|
if (opts.create)
|
|
508
563
|
args.push("-b");
|
|
509
|
-
args.push(ref);
|
|
564
|
+
args.push(ref, "--");
|
|
510
565
|
await this.mustGit(args, opts.cwd);
|
|
511
566
|
}
|
|
512
567
|
async gitBranches(cwd) {
|
|
@@ -652,6 +707,11 @@ function explainShellCommandFailure(cmd, err) {
|
|
|
652
707
|
}
|
|
653
708
|
return new Error(msg + hint);
|
|
654
709
|
}
|
|
710
|
+
function isSharedRefusal(err) {
|
|
711
|
+
if (err instanceof ActionError && err.code === "NotAvailableOnSharedIsolation")
|
|
712
|
+
return true;
|
|
713
|
+
return err instanceof Error && /NotAvailableOnSharedIsolation|not available on a shared/.test(err.message);
|
|
714
|
+
}
|
|
655
715
|
function isRouteUnavailable(err) {
|
|
656
716
|
return (err instanceof GatewayError &&
|
|
657
717
|
(err.status === 404 || err.status === 405 || err.status === 501));
|
package/dist/types.d.ts
CHANGED
|
@@ -686,8 +686,12 @@ export interface ProcessListParams {
|
|
|
686
686
|
}
|
|
687
687
|
export interface ProcessInfo {
|
|
688
688
|
pid: number;
|
|
689
|
+
/** The process name (the guest agent's `comm`). */
|
|
689
690
|
name: string;
|
|
691
|
+
/** The full command line (the guest agent's `cmdline`). */
|
|
690
692
|
cmd?: string;
|
|
693
|
+
/** Process state letter as the kernel reports it (`R`, `S`, `Z`, ...). */
|
|
694
|
+
state?: string;
|
|
691
695
|
}
|
|
692
696
|
export interface ProcessListResult {
|
|
693
697
|
processes: ProcessInfo[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solarisdk/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Shared runtime for the Solari SDK \u2014 transport, session handles, images, templates, and types used by @solarisdk/desktop and @solarisdk/sandbox.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|