@runuai/host 0.9.169 → 0.9.171

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/src/protocol.ts CHANGED
@@ -4140,6 +4140,99 @@ export interface HostCapabilities {
4140
4140
  // the per-host gh-connected state shown on the host detail page. Re-advertised
4141
4141
  // whenever a token is added/removed. Optional: older hosts omit it.
4142
4142
  githubUsers?: string[];
4143
+ /** ADR-154 §13: where this runner's switch to cloud credentials stands, and
4144
+ * what stops it. Optional: hosts before 0.9.171 omit it, and the cloud
4145
+ * then says only that the runner has not switched. */
4146
+ credentialsSwitch?: CredentialsSwitchCapability;
4147
+ }
4148
+
4149
+ /**
4150
+ * ADR-154 §13: the runner's switch to cloud credentials, as its capability
4151
+ * frame reports it. `state` is the runner's credentials mode. `stuck` is the
4152
+ * latest substantive refusal or blocker while the switch has not finished:
4153
+ * `by: "cloud"` the cloud's answer, `by: "runner"` something on the machine.
4154
+ * A transient one (the container runtime still preparing after a reconnect,
4155
+ * the bridge down) never replaces a substantive one; it shows only when
4156
+ * there is nothing else to say. Bounded and secret-free: the runner's own log
4157
+ * words (task ids, Compose project names, folder paths with the home
4158
+ * directory as `~`), never a credential.
4159
+ */
4160
+ export interface CredentialsSwitchCapability {
4161
+ state: "legacy" | "pending" | "active";
4162
+ stuck?: CredentialsSwitchStuck;
4163
+ }
4164
+
4165
+ export interface CredentialsSwitchStuck {
4166
+ by: "cloud" | "runner";
4167
+ /** The refusal or blocker in one line. */
4168
+ detail: string;
4169
+ /** Each blocker on its own, when the runner found several. */
4170
+ blockers?: string[];
4171
+ /** The `task-*` Compose projects nothing proves Uai's, which the operator
4172
+ * removes (`docker compose -p <name> down`) or renames: the first
4173
+ * `MAX_CREDENTIALS_SWITCH_PROJECTS` names, and how many there are. */
4174
+ composeProjects?: { names: string[]; total: number };
4175
+ /** Nothing is wrong yet: the runner could not look (see above). */
4176
+ transient?: boolean;
4177
+ /** Epoch ms this refusal or blocker was first seen, unchanged since. */
4178
+ since: number;
4179
+ }
4180
+
4181
+ export const MAX_CREDENTIALS_SWITCH_DETAIL_CHARS = 600;
4182
+ export const MAX_CREDENTIALS_SWITCH_BLOCKERS = 20;
4183
+ export const MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS = 300;
4184
+ export const MAX_CREDENTIALS_SWITCH_PROJECTS = 100;
4185
+
4186
+ /** The capability, exact and bounded; undefined when it is not one. The
4187
+ * cloud drops a malformed one alone, never the frame around it. */
4188
+ export function parseCredentialsSwitchCapability(value: unknown): CredentialsSwitchCapability | undefined {
4189
+ if (!isPlainRecord(value)) return undefined;
4190
+ const { state, stuck } = value;
4191
+ if (state !== "legacy" && state !== "pending" && state !== "active") return undefined;
4192
+ if (Object.keys(value).some((key) => key !== "state" && key !== "stuck")) return undefined;
4193
+ if (stuck === undefined) return { state };
4194
+ if (!isPlainRecord(stuck)) return undefined;
4195
+ const allowed = new Set(["by", "detail", "blockers", "composeProjects", "transient", "since"]);
4196
+ if (Object.keys(stuck).some((key) => !allowed.has(key))) return undefined;
4197
+ const boundedText = (text: unknown, max: number): text is string =>
4198
+ typeof text === "string" && text.length >= 1 && text.length <= max;
4199
+ if (stuck.by !== "cloud" && stuck.by !== "runner") return undefined;
4200
+ if (!boundedText(stuck.detail, MAX_CREDENTIALS_SWITCH_DETAIL_CHARS)) return undefined;
4201
+ if (typeof stuck.since !== "number" || !Number.isSafeInteger(stuck.since) || stuck.since < 0) return undefined;
4202
+ if (stuck.transient !== undefined && typeof stuck.transient !== "boolean") return undefined;
4203
+ const out: CredentialsSwitchStuck = { by: stuck.by, detail: stuck.detail, since: stuck.since };
4204
+ if (stuck.blockers !== undefined) {
4205
+ if (
4206
+ !Array.isArray(stuck.blockers) ||
4207
+ stuck.blockers.length > MAX_CREDENTIALS_SWITCH_BLOCKERS ||
4208
+ !stuck.blockers.every((blocker) => boundedText(blocker, MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS))
4209
+ ) {
4210
+ return undefined;
4211
+ }
4212
+ out.blockers = [...stuck.blockers];
4213
+ }
4214
+ if (stuck.composeProjects !== undefined) {
4215
+ const projects = stuck.composeProjects;
4216
+ if (!isPlainRecord(projects) || Object.keys(projects).some((key) => key !== "names" && key !== "total")) return undefined;
4217
+ const { names, total } = projects;
4218
+ if (
4219
+ !Array.isArray(names) ||
4220
+ names.length > MAX_CREDENTIALS_SWITCH_PROJECTS ||
4221
+ !names.every((name) => typeof name === "string" && /^task-[a-z0-9][a-z0-9_-]{0,127}$/.test(name)) ||
4222
+ typeof total !== "number" ||
4223
+ !Number.isSafeInteger(total) ||
4224
+ total < names.length
4225
+ ) {
4226
+ return undefined;
4227
+ }
4228
+ out.composeProjects = { names: [...names] as string[], total };
4229
+ }
4230
+ if (stuck.transient === true) out.transient = true;
4231
+ return { state, stuck: out };
4232
+ }
4233
+
4234
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
4235
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4143
4236
  }
4144
4237
 
4145
4238
  /** One transcript projection the cloud asks the host to append to (ADR-083). */