@skrr-ai/cli 0.1.24 → 0.1.26

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.
Files changed (87) hide show
  1. package/dist/base-command.d.ts +4 -4
  2. package/dist/base-command.js +11 -10
  3. package/dist/commands/code/install.js +13 -0
  4. package/dist/commands/harnesses/leases/billing.d.ts +9 -6
  5. package/dist/commands/harnesses/leases/billing.js +19 -24
  6. package/dist/commands/machines/dedicated/attach.d.ts +28 -0
  7. package/dist/commands/machines/dedicated/attach.js +75 -0
  8. package/dist/commands/machines/dedicated/catalog.d.ts +17 -0
  9. package/dist/commands/machines/dedicated/catalog.js +75 -0
  10. package/dist/commands/machines/dedicated/cp.d.ts +22 -0
  11. package/dist/commands/machines/dedicated/cp.js +76 -0
  12. package/dist/commands/machines/dedicated/create.d.ts +6 -2
  13. package/dist/commands/machines/dedicated/create.js +29 -20
  14. package/dist/commands/machines/dedicated/destroy.d.ts +6 -2
  15. package/dist/commands/machines/dedicated/destroy.js +41 -24
  16. package/dist/commands/machines/dedicated/exec.d.ts +28 -0
  17. package/dist/commands/machines/dedicated/exec.js +105 -0
  18. package/dist/commands/machines/dedicated/grow.d.ts +6 -2
  19. package/dist/commands/machines/dedicated/grow.js +14 -19
  20. package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
  21. package/dist/commands/machines/dedicated/health-check.js +16 -14
  22. package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
  23. package/dist/commands/machines/dedicated/incident-close.js +11 -14
  24. package/dist/commands/machines/dedicated/index.js +37 -16
  25. package/dist/commands/machines/dedicated/list.d.ts +2 -2
  26. package/dist/commands/machines/dedicated/list.js +28 -17
  27. package/dist/commands/machines/dedicated/restart.d.ts +5 -2
  28. package/dist/commands/machines/dedicated/restart.js +8 -19
  29. package/dist/commands/machines/dedicated/show.d.ts +2 -2
  30. package/dist/commands/machines/dedicated/show.js +28 -30
  31. package/dist/commands/machines/dedicated/sign-in.d.ts +19 -0
  32. package/dist/commands/machines/dedicated/sign-in.js +49 -0
  33. package/dist/commands/machines/dedicated/snapshot.d.ts +5 -2
  34. package/dist/commands/machines/dedicated/snapshot.js +11 -19
  35. package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
  36. package/dist/commands/machines/dedicated/spending-limit.js +50 -0
  37. package/dist/commands/machines/dedicated/start.d.ts +5 -2
  38. package/dist/commands/machines/dedicated/start.js +8 -19
  39. package/dist/commands/machines/dedicated/stop.d.ts +5 -2
  40. package/dist/commands/machines/dedicated/stop.js +8 -19
  41. package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
  42. package/dist/commands/machines/dedicated/terminal.js +42 -0
  43. package/dist/commands/spaces/auto-executor/preview.d.ts +8 -0
  44. package/dist/commands/spaces/auto-executor/preview.js +41 -0
  45. package/dist/commands/spaces/task-rollups.d.ts +14 -0
  46. package/dist/commands/spaces/task-rollups.js +66 -0
  47. package/dist/commands/spaces/work-sync/telemetry.js +22 -0
  48. package/dist/commands/tasks/assign.js +1 -1
  49. package/dist/commands/tasks/create.js +10 -4
  50. package/dist/commands/tasks/list.js +27 -0
  51. package/dist/lib/api-fetch.js +9 -1
  52. package/dist/lib/assignee-resolver.d.ts +7 -6
  53. package/dist/lib/daemonBroker.d.ts +28 -0
  54. package/dist/lib/daemonBroker.js +60 -19
  55. package/dist/lib/dedicated-copy.d.ts +22 -0
  56. package/dist/lib/dedicated-copy.js +115 -0
  57. package/dist/lib/dedicated-lease-command.d.ts +50 -0
  58. package/dist/lib/dedicated-lease-command.js +158 -0
  59. package/dist/lib/dedicated-machines.d.ts +248 -12
  60. package/dist/lib/dedicated-machines.js +385 -25
  61. package/dist/lib/dedicated-terminal.d.ts +89 -0
  62. package/dist/lib/dedicated-terminal.js +275 -0
  63. package/dist/lib/dedicated-wait.d.ts +72 -0
  64. package/dist/lib/dedicated-wait.js +195 -0
  65. package/dist/lib/harnesses.d.ts +6 -0
  66. package/dist/lib/idempotency-scope.d.ts +10 -0
  67. package/dist/lib/idempotency-scope.js +16 -0
  68. package/dist/lib/node-adapter.d.ts +1 -1
  69. package/dist/lib/node-adapter.js +13 -11
  70. package/dist/lib/sky-code.d.ts +30 -1
  71. package/dist/lib/sky-code.js +44 -2
  72. package/dist/lib/triggers.js +7 -0
  73. package/dist/lib/work-sync.d.ts +35 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +2 -1
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +71 -21
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +10 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +17 -1
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +71 -21
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +10 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +16 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  85. package/dist/node_modules/@skrr-ai/data-provider/index.js +2657 -2596
  86. package/oclif.manifest.json +5075 -4294
  87. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../base-command';
2
+ /**
3
+ * `skrr spaces task-rollups` — task-status counts for every Space you can view,
4
+ * in one request: the same rollup the Spaces sidebar row reads, counted by the
5
+ * project-rollup rules (`done` is done only; `open` excludes cancelled).
6
+ */
7
+ export default class SpacesTaskRollups extends BaseCommand {
8
+ static description: string;
9
+ static examples: string[];
10
+ static flags: {
11
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const data_provider_1 = require("@skrr-ai/data-provider");
5
+ const base_command_1 = require("../../base-command");
6
+ /**
7
+ * `skrr spaces task-rollups` — task-status counts for every Space you can view,
8
+ * in one request: the same rollup the Spaces sidebar row reads, counted by the
9
+ * project-rollup rules (`done` is done only; `open` excludes cancelled).
10
+ */
11
+ class SpacesTaskRollups extends base_command_1.BaseCommand {
12
+ static description = 'Show task-status counts for every space you can view';
13
+ static examples = [
14
+ '<%= config.bin %> spaces task-rollups',
15
+ '<%= config.bin %> spaces task-rollups --json',
16
+ ];
17
+ static flags = {
18
+ json: core_1.Flags.boolean({ description: 'Output as JSON' }),
19
+ };
20
+ async run() {
21
+ this.requireAuth();
22
+ const { flags } = await this.parse(SpacesTaskRollups);
23
+ let response;
24
+ // Titles are best-effort: the rollup carries only ids, and a failed title
25
+ // lookup must not hide counts the rollup did return.
26
+ let titles = new Map();
27
+ try {
28
+ const [rollups, spaces] = await Promise.all([
29
+ data_provider_1.dataService.listSpaceTaskRollups(),
30
+ data_provider_1.dataService.listSpaces({ includeArchived: true, limit: 500 }).catch(() => null),
31
+ ]);
32
+ response = rollups;
33
+ const rows = (spaces?.data ||
34
+ []);
35
+ titles = new Map(rows.map((space) => [String(space.id), space.title || '']));
36
+ }
37
+ catch (err) {
38
+ this.handleApiError(err);
39
+ return;
40
+ }
41
+ if (flags.json) {
42
+ this.log(JSON.stringify(response, null, 2));
43
+ return;
44
+ }
45
+ if (response.degraded) {
46
+ // A partial walk is not the whole picture; say so before the numbers.
47
+ this.log(`Degraded: ${(response.warnings || []).join('; ') || 'some spaces were not counted'}`);
48
+ }
49
+ const rows = [...(response.rollups || [])].sort((a, b) => b.tasks.open - a.tasks.open);
50
+ if (rows.length === 0) {
51
+ this.log('No spaces with tasks.');
52
+ return;
53
+ }
54
+ const label = (spaceId) => {
55
+ const title = titles.get(spaceId);
56
+ const text = title ? `${title} (${spaceId.slice(0, 8)})` : spaceId;
57
+ return text.length > 48 ? `${text.slice(0, 45)}...` : text;
58
+ };
59
+ this.log(`${'SPACE'.padEnd(48)} ${'OPEN'.padStart(5)} ${'DONE'.padStart(5)} ${'BLOCKED'.padStart(7)} ${'TOTAL'.padStart(5)}`);
60
+ for (const { spaceId, tasks } of rows) {
61
+ this.log(`${label(spaceId).padEnd(48)} ${String(tasks.open).padStart(5)} ${String(tasks.done).padStart(5)} ` +
62
+ `${String(tasks.blocked).padStart(7)} ${String(tasks.total).padStart(5)}`);
63
+ }
64
+ }
65
+ }
66
+ exports.default = SpacesTaskRollups;
@@ -52,6 +52,28 @@ class SpacesWorkSyncTelemetry extends base_command_1.BaseCommand {
52
52
  this.log(`Verdict: ${summary.rollout.verdict}`);
53
53
  this.log(`Sampled rows: ${summary.sampledEvents}`);
54
54
  this.log(`Repair backlog: ${summary.repairBacklog}`);
55
+ if (summary.coverage && summary.coverage.hits + summary.coverage.misses > 0) {
56
+ const { hits, misses, hitRate } = summary.coverage;
57
+ this.log(`Coverage: ${hits} hits · ${misses} misses` +
58
+ (hitRate === null ? '' : ` (${Math.round(hitRate * 1000) / 10}% of cold opens)`));
59
+ }
60
+ // Before the zero-events early return: writes are measured from the command
61
+ // queue, not from board telemetry, so a Space can have one without the other.
62
+ const commandRows = Object.entries(summary.commands || {}).sort(([a], [b]) => a.localeCompare(b));
63
+ if (commandRows.length > 0) {
64
+ const percent = (value) => value === null ? 'n/a' : `${Math.round(value * 1000) / 10}%`;
65
+ this.log('');
66
+ this.log('Writes (durable command queue):');
67
+ for (const [kind, stats] of commandRows) {
68
+ this.log(` ${kind.padEnd(20)} ${stats.commands} commands · ${stats.accepted} accepted · ` +
69
+ `${stats.pending} pending · ${stats.deadLettered} refused`);
70
+ this.log(` ${''.padEnd(20)} version conflicts ${stats.versionConflicts} ` +
71
+ `(${percent(stats.rates.versionConflict)} of settled) · authorization changed ` +
72
+ `${stats.authorizationChanged} (${percent(stats.rates.authorizationChanged)}) · ` +
73
+ `duplicate deliveries ${stats.duplicateDeliveries} ` +
74
+ `(${percent(stats.rates.duplicateDelivery)} of deliveries)`);
75
+ }
76
+ }
55
77
  // Zero sampled rows is a REAL answer and the most misread one: it means
56
78
  // nothing has been measured in this Space, not that everything is fine. The
57
79
  // server already distinguishes it (`insufficient_evidence`); say so here too
@@ -74,7 +74,7 @@ class TasksAssign extends base_command_1.BaseCommand {
74
74
  options: ['agent', 'human'],
75
75
  }),
76
76
  list: core_1.Flags.boolean({
77
- description: "List the task's space members (who can be assigned) and exit",
77
+ description: "List the task's space members and exit (your own unshared agents can also be assigned, by id)",
78
78
  }),
79
79
  json: core_1.Flags.boolean({ description: 'Output as JSON' }),
80
80
  };
@@ -460,8 +460,9 @@ class TasksCreate extends base_command_1.BaseCommand {
460
460
  defaultAgentResolution = {
461
461
  applied: false,
462
462
  source: 'none',
463
- note: 'Created unassigned: the agent bound to this session is not a member of that space. ' +
464
- 'Add it to the space, or pass an assignee explicitly.',
463
+ note: 'Created unassigned: the agent bound to this session is not a member of that space, ' +
464
+ 'and it is not your own agent shared with no one else, which is the only other way ' +
465
+ 'an agent can work there. Add it to the space, or pass an assignee explicitly.',
465
466
  };
466
467
  try {
467
468
  const result = await this.createTaskOrRecover(payload);
@@ -572,9 +573,14 @@ class TasksCreate extends base_command_1.BaseCommand {
572
573
  const dropped = (0, tasks_1.droppedDefaultAssignees)(resolution, memberIds);
573
574
  const warnings = [];
574
575
  if (dropped.agentIds.length > 0) {
576
+ // Membership is one of TWO consents the server accepts (AgentTaskConsent);
577
+ // the other — your own agent, shared with no one else — needs the agent's
578
+ // sharing, which a dry run does not read. So this says what decides it
579
+ // rather than predicting a drop that owner delegation would not make.
575
580
  warnings.push(`The defaulted assignee agent ${dropped.agentIds.join(', ')} is not a member of ` +
576
- `space ${spaceId}, so the real create will drop it and record the task ` +
577
- 'unassigned. Add the agent to the space, or pass an assignee explicitly.');
581
+ `space ${spaceId}. The real create keeps it only if it is your own agent shared ` +
582
+ 'with no one else; otherwise it records the task unassigned. Add the agent to the ' +
583
+ 'space, or pass an assignee explicitly.');
578
584
  }
579
585
  if (dropped.userIds.length > 0) {
580
586
  warnings.push(`The defaulted assignee ${dropped.userIds.join(', ')} is not a member of space ` +
@@ -86,6 +86,33 @@ function emptyTasksMessage(flags, bin = 'skrr') {
86
86
  lines.push(' no longer has that status. Try `--execution-state active` (running now) or');
87
87
  lines.push(` \`--execution-state recovered\` (reaped, startable), or \`${bin} tasks active\`.`);
88
88
  }
89
+ else if (typeof flags.status === 'string' &&
90
+ flags.status.length > 0 &&
91
+ !flags['execution-state']) {
92
+ // A filter that matches nothing and a board that IS empty print the same
93
+ // thing, and the caller cannot tell which they are looking at. Observed:
94
+ // `--status todo` on a board whose open column is `backlog` said `No tasks.`
95
+ // while twelve were open, and it was believed.
96
+ //
97
+ // Not fixed by validating the value, for two reasons. A status is a
98
+ // Space-owned workflow-state id — TASK_STATUSES is a legacy default set, not
99
+ // a closed vocabulary — so a space may legitimately define one we have never
100
+ // heard of. And `todo` IS a built-in, so refusing unknown values would not
101
+ // have caught the case that prompted this. What distinguishes the two
102
+ // readings is naming the filter and how to drop it.
103
+ //
104
+ // Silent when `--execution-state` is also set, to leave the exact bare
105
+ // string the branch above deliberately produces for that combination. The
106
+ // same ambiguity exists there, but overturning a pinned decision is not
107
+ // this change's business.
108
+ lines.push('');
109
+ lines.push(`Note: nothing matched \`--status ${flags.status}\`. A status is a workflow-state id the`);
110
+ lines.push(' space owns, so a board need not use this one at all.');
111
+ lines.push(' → See what this scope actually holds: the same command without `--status`.');
112
+ if (!data_provider_1.TASK_STATUSES.includes(flags.status)) {
113
+ lines.push(` (built-in ids: ${data_provider_1.TASK_STATUSES.join(', ')})`);
114
+ }
115
+ }
89
116
  return lines.join('\n');
90
117
  }
91
118
  /**
@@ -37,6 +37,8 @@ const config_1 = require("./config");
37
37
  // (b) tests that mock './auth-storage' get a single mock target.
38
38
  const auth_storage_1 = require("./auth-storage");
39
39
  const credential_resolver_1 = require("./credential-resolver");
40
+ const data_provider_1 = require("@skrr-ai/data-provider");
41
+ const idempotency_scope_1 = require("./idempotency-scope");
40
42
  const node_adapter_1 = require("./node-adapter");
41
43
  const refresh_1 = require("./refresh");
42
44
  const delegated_cli_1 = require("./delegated-cli");
@@ -95,6 +97,12 @@ async function apiFetch(pathOrUrl, opts = {}) {
95
97
  if (opts.body !== undefined && method !== 'GET') {
96
98
  body = JSON.stringify(opts.body);
97
99
  }
100
+ // Same rule as every `dataService` write (`@skrr-ai/data-provider`
101
+ // idempotency), chosen once so the 401 retries below resend the same key.
102
+ (0, idempotency_scope_1.bindAutonomousIdempotencyScope)();
103
+ const callerHeaders = method === 'GET'
104
+ ? (opts.headers ?? {})
105
+ : (0, data_provider_1.withIdempotencyKey)(opts.headers, { method, url: pathOrUrl, body: opts.body });
98
106
  // A trusted Agent process cannot smuggle an explicit human credential into
99
107
  // this escape hatch. The daemon-issued session token is the sole principal.
100
108
  //
@@ -146,7 +154,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
146
154
  // Caller headers are applied BEFORE the auth header below is read, and
147
155
  // deliberately cannot clobber Authorization / Content-Type: an idempotency
148
156
  // key is additive metadata, not a way to re-auth the request.
149
- for (const [k, v] of Object.entries(opts.headers ?? {})) {
157
+ for (const [k, v] of Object.entries(callerHeaders)) {
150
158
  const lower = k.toLowerCase();
151
159
  if (lower === 'authorization' || lower === 'content-type')
152
160
  continue;
@@ -9,12 +9,13 @@
9
9
  * reference into exactly one member of the task's space, or an actionable
10
10
  * failure that names the candidates.
11
11
  *
12
- * Scoping to the space is not just ergonomics. The server rejects assignees
13
- * that are not space members (`SpacePermissionService.validateAssigneeSpaceMembership`
14
- * → `ASSIGNEE_NOT_IN_SPACE`), so resolving against the space membership list
15
- * means a name that resolves is a name that will be accepted — we never build
16
- * a request the server is guaranteed to refuse, and we cannot accidentally
17
- * leak a directory-wide user search into a space the caller can't see.
12
+ * Scoping to the space is not just ergonomics. The server accepts space members
13
+ * and, beyond them, only the caller's own agent when it is shared with no one
14
+ * else (`AgentTaskConsent` → otherwise `ASSIGNEE_NOT_IN_SPACE`), so a name that
15
+ * resolves against the membership list is a name that will be accepted, and we
16
+ * cannot accidentally leak a directory-wide user search into a space the caller
17
+ * can't see. An owner-delegated agent is not a member and so is not resolvable
18
+ * by name here; it is passed by raw id.
18
19
  */
19
20
  export type MemberKind = 'user' | 'agent';
20
21
  export type SpaceMember = {
@@ -36,6 +36,7 @@
36
36
  * liveness with `process.kill(pid, 0)` to filter stale files left behind
37
37
  * by a force-killed daemon.
38
38
  */
39
+ import { type AuthBackend } from './auth-storage';
39
40
  import { type CliConfig } from './config';
40
41
  /**
41
42
  * Wire-shape of the bootstrap file. Mirrors
@@ -86,6 +87,13 @@ export interface DaemonBrokerResult {
86
87
  * the mint/confirm split, where the mint still revoked eagerly.
87
88
  */
88
89
  familyId?: string;
90
+ /**
91
+ * Where `attemptDaemonBrokerLoginAndPersist` actually stored the credential.
92
+ * Callers label the token's source from this, not from the platform: Keychain
93
+ * only exists on macOS, and even there a failed Keychain write falls back to
94
+ * the file.
95
+ */
96
+ storedIn?: Exclude<AuthBackend, 'none'>;
89
97
  }
90
98
  /**
91
99
  * Explicit reasons the broker path could fail. These exist to keep the
@@ -139,6 +147,8 @@ export interface DaemonBrokerOptions {
139
147
  profile?: string;
140
148
  /** Override the bootstrap file path entirely (tests / unusual layouts). */
141
149
  bootstrapPathOverride?: string;
150
+ /** Test seam: the ordered candidate list, when one path is not enough. */
151
+ candidatePathsOverride?: readonly string[];
142
152
  /** Override total request timeout. Tests use a short value. */
143
153
  timeoutMs?: number;
144
154
  }
@@ -159,6 +169,24 @@ export declare function resolveProfileBootstrapPaths(primary: string, entryNames
159
169
  * deterministic when more than one matching daemon is running.
160
170
  */
161
171
  export declare function resolveBootstrapCandidatePaths(profile?: string): string[];
172
+ /**
173
+ * A Dedicated Runtime guest's workload hand-off descriptor.
174
+ *
175
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
176
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
177
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
178
+ * this second descriptor to the shared workload group instead
179
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
180
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
181
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
182
+ * submission also reads and which needs the island secret.
183
+ *
184
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
185
+ * user configures can point the broker somewhere else.
186
+ */
187
+ export declare const DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-dedicated-runtime/cli-handoff/descriptor.json";
188
+ /** Where the cli-handoff broker looks, in order. */
189
+ export declare function resolveCliHandoffCandidatePaths(profile?: string): string[];
162
190
  /**
163
191
  * Resolve the first live daemon bootstrap for a profile. Message-intent
164
192
  * submission reuses the same authenticated loopback discovery as the login
@@ -71,10 +71,12 @@ var __importStar = (this && this.__importStar) || (function () {
71
71
  };
72
72
  })();
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = void 0;
74
75
  exports.normalizeBaseUrl = normalizeBaseUrl;
75
76
  exports.resolveBootstrapPath = resolveBootstrapPath;
76
77
  exports.resolveProfileBootstrapPaths = resolveProfileBootstrapPaths;
77
78
  exports.resolveBootstrapCandidatePaths = resolveBootstrapCandidatePaths;
79
+ exports.resolveCliHandoffCandidatePaths = resolveCliHandoffCandidatePaths;
78
80
  exports.findLiveDaemonBootstrap = findLiveDaemonBootstrap;
79
81
  exports.readBootstrap = readBootstrap;
80
82
  exports.attemptDaemonBrokerLogin = attemptDaemonBrokerLogin;
@@ -164,6 +166,33 @@ function resolveBootstrapCandidatePaths(profile = 'default') {
164
166
  return process.platform === 'darwin' ? [primary] : [primary, legacyBootstrapPath()];
165
167
  }
166
168
  }
169
+ /**
170
+ * A Dedicated Runtime guest's workload hand-off descriptor.
171
+ *
172
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
173
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
174
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
175
+ * this second descriptor to the shared workload group instead
176
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
177
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
178
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
179
+ * submission also reads and which needs the island secret.
180
+ *
181
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
182
+ * user configures can point the broker somewhere else.
183
+ */
184
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-dedicated-runtime/cli-handoff/descriptor.json';
185
+ /** Where the cli-handoff broker looks, in order. */
186
+ function resolveCliHandoffCandidatePaths(profile = 'default') {
187
+ const candidates = resolveBootstrapCandidatePaths(profile);
188
+ // Any profile: a guest runs one daemon, and the auto-broker resolves the
189
+ // default profile regardless of `--profile`, so a narrower condition here would
190
+ // only describe behaviour production never reaches.
191
+ if (process.platform === 'linux') {
192
+ candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
193
+ }
194
+ return candidates;
195
+ }
167
196
  /**
168
197
  * Pre-Phase-K bootstrap path (non-darwin). Read-only fallback consulted
169
198
  * when the canonical path is absent — covers a freshly-upgraded daemon
@@ -247,13 +276,15 @@ function readBootstrap(filePath) {
247
276
  */
248
277
  async function attemptDaemonBrokerLogin(opts) {
249
278
  const profile = opts.profile ?? 'default';
250
- const candidates = opts.bootstrapPathOverride
251
- ? [opts.bootstrapPathOverride]
252
- : resolveBootstrapCandidatePaths(profile);
253
- let bootstrap = null;
254
- let usedPath = null;
255
- let unboundBootstrap = null;
256
- let unboundPath = null;
279
+ const candidates = opts.candidatePathsOverride
280
+ ? [...opts.candidatePathsOverride]
281
+ : opts.bootstrapPathOverride
282
+ ? [opts.bootstrapPathOverride]
283
+ : resolveCliHandoffCandidatePaths(profile);
284
+ // Bound candidates (their serverUrl matches this CLI) in order, then unbound
285
+ // ones. Every usable candidate is kept, not just the first: see below.
286
+ const usable = [];
287
+ const unbound = [];
257
288
  let mismatchedBootstrap = null;
258
289
  for (const candidate of candidates) {
259
290
  const candidateBootstrap = readBootstrap(candidate);
@@ -269,19 +300,13 @@ async function attemptDaemonBrokerLogin(opts) {
269
300
  }
270
301
  if (typeof candidateBootstrap.serverUrl !== 'string' ||
271
302
  candidateBootstrap.serverUrl.length === 0) {
272
- unboundBootstrap ??= candidateBootstrap;
273
- unboundPath ??= candidate;
303
+ unbound.push(candidateBootstrap);
274
304
  continue;
275
305
  }
276
- bootstrap = candidateBootstrap;
277
- usedPath = candidate;
278
- break;
279
- }
280
- if (!bootstrap && unboundBootstrap && unboundPath) {
281
- bootstrap = unboundBootstrap;
282
- usedPath = unboundPath;
306
+ usable.push(candidateBootstrap);
283
307
  }
284
- if (!bootstrap || !usedPath) {
308
+ usable.push(...unbound);
309
+ if (usable.length === 0) {
285
310
  if (mismatchedBootstrap?.serverUrl) {
286
311
  return {
287
312
  ok: false,
@@ -296,6 +321,22 @@ async function attemptDaemonBrokerLogin(opts) {
296
321
  detail: `No daemon bootstrap file found (looked in ${candidates.join(', ')})`,
297
322
  };
298
323
  }
324
+ // A candidate nothing is listening behind moves on to the next one. Liveness is
325
+ // only a pid check, and EPERM counts as alive — on a Dedicated Runtime guest
326
+ // nearly every pid belongs to another uid, so a stale island file in the
327
+ // workload's own home (restored dotfiles, say) passes it and used to shadow the
328
+ // daemon's live hand-off descriptor forever, with `skrr` reporting "Not signed
329
+ // in". Only a refused connection falls through: any answer, or a timeout, is a
330
+ // daemon that is really there, and its outcome stands.
331
+ let outcome = null;
332
+ for (const bootstrap of usable) {
333
+ outcome = await brokerThrough(bootstrap, opts);
334
+ if (outcome.ok || outcome.reason !== 'network')
335
+ return outcome;
336
+ }
337
+ return outcome;
338
+ }
339
+ async function brokerThrough(bootstrap, opts) {
299
340
  const url = `http://${bootstrap.host}:${bootstrap.port}/v1/auth/cli-handoff`;
300
341
  const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
301
342
  const controller = new AbortController();
@@ -486,7 +527,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
486
527
  if (!serverOrigin) {
487
528
  throw new Error('Daemon-broker credential persistence requires a valid CLI baseURL.');
488
529
  }
489
- (0, auth_storage_1.writeToBackend)({
530
+ const storedIn = (0, auth_storage_1.writeToBackend)({
490
531
  token: outcome.accessToken,
491
532
  refreshToken: outcome.refreshToken,
492
533
  ...(expiryToEpochMs(outcome.accessExpiresAt) !== undefined
@@ -507,7 +548,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
507
548
  familyId: outcome.familyId,
508
549
  });
509
550
  }
510
- return outcome;
551
+ return storedIn === 'none' ? outcome : { ...outcome, storedIn };
511
552
  }
512
553
  // ---------------------------------------------------------------------
513
554
  // Auto-broker on first command (BaseCommand.init hook)
@@ -0,0 +1,22 @@
1
+ import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type DedicatedRuntimeScope } from './dedicated-machines';
2
+ /**
3
+ * The two copy loops behind `skrr machines dedicated cp`.
4
+ *
5
+ * One API request per 512 KiB chunk, so every chunk passes the same owner
6
+ * check, generation fence and daemon file policy as any other access to the
7
+ * machine. Neither end is replaced until the whole file has arrived: an upload
8
+ * stages beside its destination on the machine and the final chunk renames it;
9
+ * a download writes a temporary file beside its local destination and renames.
10
+ */
11
+ export interface DedicatedCopyIo {
12
+ readChunk: typeof readDedicatedRuntimeFileChunk;
13
+ writeChunk: typeof writeDedicatedRuntimeFileChunk;
14
+ chunkBytes: number;
15
+ maxBytes: number;
16
+ }
17
+ export interface DedicatedCopyResult {
18
+ bytes: number;
19
+ destination: string;
20
+ }
21
+ export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope?: DedicatedRuntimeScope, io?: Partial<DedicatedCopyIo>): Promise<DedicatedCopyResult>;
22
+ export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope?: DedicatedRuntimeScope, io?: Partial<DedicatedCopyIo>): Promise<DedicatedCopyResult>;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.uploadToDedicatedRuntime = uploadToDedicatedRuntime;
4
+ exports.downloadFromDedicatedRuntime = downloadFromDedicatedRuntime;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const promises_1 = require("node:fs/promises");
7
+ const node_path_1 = require("node:path");
8
+ const dedicated_machines_1 = require("./dedicated-machines");
9
+ const defaultIo = {
10
+ readChunk: dedicated_machines_1.readDedicatedRuntimeFileChunk,
11
+ writeChunk: dedicated_machines_1.writeDedicatedRuntimeFileChunk,
12
+ chunkBytes: dedicated_machines_1.DEDICATED_FILE_CHUNK_BYTES,
13
+ maxBytes: dedicated_machines_1.DEDICATED_FILE_MAX_BYTES,
14
+ };
15
+ function mib(bytes) {
16
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MiB`;
17
+ }
18
+ async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope = {}, io = {}) {
19
+ const { writeChunk, chunkBytes, maxBytes } = { ...defaultIo, ...io };
20
+ const info = await (0, promises_1.stat)(localPath);
21
+ if (!info.isFile()) {
22
+ throw new Error(`${localPath} is not a regular file`);
23
+ }
24
+ if (info.size > maxBytes) {
25
+ throw new Error(`${localPath} is ${mib(info.size)}; copy is limited to ${mib(maxBytes)}. Push it through Git or object storage instead.`);
26
+ }
27
+ const transferId = `cp-${Date.now().toString(36)}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
28
+ const handle = await (0, promises_1.open)(localPath, 'r');
29
+ try {
30
+ const buffer = Buffer.alloc(chunkBytes);
31
+ let offset = 0;
32
+ for (;;) {
33
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset);
34
+ const final = offset + bytesRead >= info.size;
35
+ if (!final && bytesRead === 0) {
36
+ throw new Error(`${localPath} shrank while it was being copied`);
37
+ }
38
+ const result = await writeChunk(leaseId, {
39
+ path: remotePath,
40
+ transferId,
41
+ offset,
42
+ dataBase64: buffer.subarray(0, bytesRead).toString('base64'),
43
+ // A file that GREW is caught here: the machine refuses to commit
44
+ // when what it staged is not the size declared at the start.
45
+ ...(final ? { final: true, totalSize: info.size } : {}),
46
+ }, scope);
47
+ offset += bytesRead;
48
+ if (final) {
49
+ return { bytes: offset, destination: `${leaseId}:${result.path || remotePath}` };
50
+ }
51
+ }
52
+ }
53
+ catch (err) {
54
+ // Best effort: remove the staged partial so it does not sit beside the
55
+ // destination. The destination itself was never touched.
56
+ await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true }, scope).catch(() => undefined);
57
+ throw err;
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope = {}, io = {}) {
64
+ const { readChunk, chunkBytes } = { ...defaultIo, ...io };
65
+ let target = localPath;
66
+ try {
67
+ if ((await (0, promises_1.stat)(localPath)).isDirectory()) {
68
+ target = (0, node_path_1.join)(localPath, (0, node_path_1.basename)(remotePath));
69
+ }
70
+ }
71
+ catch {
72
+ // Does not exist yet: it names the file.
73
+ }
74
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(target), { recursive: true });
75
+ const temporary = (0, node_path_1.join)((0, node_path_1.dirname)(target), `.${(0, node_path_1.basename)(target)}.skrr-download-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`);
76
+ const handle = await (0, promises_1.open)(temporary, 'wx');
77
+ let closed = false;
78
+ let offset = 0;
79
+ let size = -1;
80
+ let mtimeMs = -1;
81
+ try {
82
+ for (;;) {
83
+ const chunk = await readChunk(leaseId, { path: remotePath, offset, length: chunkBytes }, scope);
84
+ if (size === -1) {
85
+ size = chunk.size;
86
+ mtimeMs = chunk.mtimeMs;
87
+ }
88
+ else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs) {
89
+ // Chunks from two versions of a file splice into one that never existed.
90
+ throw new Error(`${remotePath} changed on the machine while it was being copied`);
91
+ }
92
+ const bytes = Buffer.from(chunk.dataBase64, 'base64');
93
+ if (bytes.byteLength !== chunk.bytesRead) {
94
+ throw new Error('A chunk arrived damaged; nothing was written to the destination');
95
+ }
96
+ await handle.write(bytes, 0, bytes.byteLength, offset);
97
+ offset += bytes.byteLength;
98
+ if (chunk.eof)
99
+ break;
100
+ if (bytes.byteLength === 0) {
101
+ throw new Error(`${remotePath} ended before its reported size`);
102
+ }
103
+ }
104
+ await handle.close();
105
+ closed = true;
106
+ await (0, promises_1.rename)(temporary, target);
107
+ return { bytes: offset, destination: target };
108
+ }
109
+ catch (err) {
110
+ if (!closed)
111
+ await handle.close().catch(() => undefined);
112
+ await (0, promises_1.unlink)(temporary).catch(() => undefined);
113
+ throw err;
114
+ }
115
+ }
@@ -0,0 +1,50 @@
1
+ import { BaseCommand } from '../base-command';
2
+ import { type DedicatedRuntimeResponse, type DedicatedRuntimeScope } from './dedicated-machines';
3
+ import { type DedicatedWaitGoal } from './dedicated-wait';
4
+ /**
5
+ * Lifecycle calls answer 202 with the lease already in its transitional state.
6
+ * `--wait` watches it to the END of the operation, which for snapshot and grow
7
+ * is a field, not a state (see `dedicated-wait.ts`).
8
+ */
9
+ export declare const dedicatedWaitFlags: {
10
+ wait: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
11
+ timeout: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ interval: import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
13
+ };
14
+ export declare abstract class DedicatedLeaseCommand extends BaseCommand {
15
+ /**
16
+ * `--workspace` is a BaseCommand flag every command already accepts (it sets
17
+ * `X-Workspace-Id` for the whole invocation). It is also passed as the scope
18
+ * query, the way `create`, `exec` and `cp` always have, so the scope a request
19
+ * was made in is visible in the request itself.
20
+ */
21
+ protected scopeFrom(flags: {
22
+ workspace?: string;
23
+ }): DedicatedRuntimeScope;
24
+ /**
25
+ * Open an interactive terminal on a lease's machine and run it to the end,
26
+ * exiting with the shell's own code.
27
+ *
28
+ * Every refusal is decided BEFORE the socket opens where it can be — a stopped
29
+ * machine, a daemon that is not connected, stdin that is not a terminal — so
30
+ * the caller gets a sentence that says what to do instead of a start that
31
+ * hangs and times out.
32
+ */
33
+ protected openDedicatedTerminal(leaseId: string, flags: {
34
+ workspace?: string;
35
+ cwd?: string;
36
+ }, initialCommand?: string): Promise<void>;
37
+ /** Run an API call, turning a refusal into the server's own sentence. */
38
+ protected callDedicated<T>(fn: () => Promise<T>): Promise<T>;
39
+ /**
40
+ * Print the lease an action returned, or wait for its operation to finish
41
+ * first when `--wait` was passed.
42
+ */
43
+ protected reportLeaseAction(response: DedicatedRuntimeResponse | undefined, flags: {
44
+ json?: boolean;
45
+ wait?: boolean;
46
+ timeout?: string;
47
+ interval?: number;
48
+ workspace?: string;
49
+ }, goal: DedicatedWaitGoal | null, verb: string): Promise<void>;
50
+ }