@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 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
- return this.rpcStream("exec", { cmd, args: opts.args ?? [], cwd: opts.cwd, timeoutMs: opts.timeoutMs, stream: true }, (c) => {
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
- onChunk({ stream: c.stream, text: new TextDecoder().decode(bytes), bytes });
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
- /** Splice basic-auth creds into an https remote URL (for private remotes). */
204
- private authUrl;
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
- this.channel.onFrame("cmd.data", cmdId, (f) => {
124
- const stream = f.stream ?? "stdout";
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
- if (this.hooks.exec &&
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
- !this.channel.connected) {
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
- const r = await this.hooks.exec(this.id, {
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
- const handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
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
- const detail = (r.stderr || r.stdout || "").trim();
388
- throw new Error(`git ${args[0]} failed (exit ${r.exitCode}): ${detail}`);
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
- authUrl(url, username, password) {
430
+ gitAuth(remoteUrl, username, password) {
393
431
  if (!username && !password)
394
- return url;
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(url);
397
- if (username)
398
- u.username = encodeURIComponent(username);
399
- if (password)
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 url;
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 args = ["clone"];
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(this.authUrl(url, opts.username, opts.password));
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
- const cfg = [];
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.runGit(["remote", "get-url", remote], opts.cwd)).stdout.trim();
494
- if (plain) {
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.2",
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",