@runuai/host 0.9.105 → 0.9.107

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.
@@ -279,10 +279,19 @@ async function hostEgressIp(): Promise<string> {
279
279
  );
280
280
  }
281
281
 
282
+ /** How long a resolved payload AMI is trusted before the newest published
283
+ * image is looked up again. Long enough that a burst of launches shares one
284
+ * describe-images; short enough that a freshly published image reaches every
285
+ * RUNNING host — shared and BYO — on its next task without a host restart
286
+ * (before 0.9.106 the answer was kept for the process lifetime). */
287
+ export const PAYLOAD_AMI_TTL_MS = 5 * 60_000;
288
+
282
289
  export function createAwsMachineProvider(
283
290
  config: AwsMachineConfig,
284
291
  runner: Runner = defaultRunner(config.region),
292
+ deps: { now?: () => number } = {},
285
293
  ): MachineProvider {
294
+ const now = deps.now ?? (() => Date.now());
286
295
  const taskIdOf = (machineId: string): string => {
287
296
  if (!machineId.startsWith("uai-machine-")) {
288
297
  throw new Error(`not a uai machine id: ${machineId}`);
@@ -332,10 +341,12 @@ export function createAwsMachineProvider(
332
341
  // orchestrator can never reach.
333
342
  // -------------------------------------------------------------------------
334
343
 
335
- let resolvedImage: string | null = null;
344
+ let resolvedImage: { id: string; at: number } | null = null;
336
345
  async function resolveImage(specImage: string): Promise<string> {
337
346
  if (specImage.startsWith("ami-")) return specImage;
338
- if (resolvedImage) return resolvedImage;
347
+ if (resolvedImage && now() - resolvedImage.at < PAYLOAD_AMI_TTL_MS) {
348
+ return resolvedImage.id;
349
+ }
339
350
  const res = await runner([
340
351
  "ec2",
341
352
  "describe-images",
@@ -356,12 +367,24 @@ export function createAwsMachineProvider(
356
367
  }
357
368
  }
358
369
  if (typeof image !== "string" || !image.startsWith("ami-")) {
370
+ // A stale-but-known image beats a failed launch when the lookup itself
371
+ // hiccups; only a host that never resolved one has nothing to offer.
372
+ if (resolvedImage) return resolvedImage.id;
359
373
  throw new Error(
360
374
  `no uai machine payload AMI is published in ${config.region} — set ` +
361
375
  "UAI_MACHINE_IMAGE to an AMI id, or ask Uai to publish this region",
362
376
  );
363
377
  }
364
- resolvedImage = image;
378
+ if (resolvedImage?.id !== image) {
379
+ // The journal line that tells an operator which payload a task got —
380
+ // an exit 127 for a missing CLI is otherwise the only symptom of a
381
+ // region that never received the latest publish.
382
+ console.log(
383
+ `[machine-aws] payload image for ${config.region}: ${image}` +
384
+ (resolvedImage ? ` (was ${resolvedImage.id})` : ""),
385
+ );
386
+ }
387
+ resolvedImage = { id: image, at: now() };
365
388
  return image;
366
389
  }
367
390
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.105",
3
+ "version": "0.9.107",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -123,6 +123,7 @@ import {
123
123
  renameEngineAccount,
124
124
  reorderEngineAccounts,
125
125
  } from "../lib/engine-accounts";
126
+ import { disconnectEngine } from "../lib/engines";
126
127
 
127
128
  export {
128
129
  parseMentions,
@@ -1271,6 +1272,16 @@ export const hostCommands: HostCommands = {
1271
1272
  }
1272
1273
  },
1273
1274
 
1275
+ async engineDisconnect(ctx, input) {
1276
+ logCommand(ctx, "engineDisconnect", input.engine);
1277
+ try {
1278
+ disconnectEngine(input.engine);
1279
+ return ok({ disconnected: true });
1280
+ } catch (err) {
1281
+ return failFromUnknown(err);
1282
+ }
1283
+ },
1284
+
1274
1285
  async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
1275
1286
  logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
1276
1287
  try {
package/src/main.ts CHANGED
@@ -188,6 +188,7 @@ import {
188
188
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
189
189
  HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
190
190
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
191
+ HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
191
192
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
192
193
  isEngineAccountScope,
193
194
  HOST_LOGS_PROTOCOL_FEATURE,
@@ -232,6 +233,7 @@ import {
232
233
  type FilesOpInput,
233
234
  engineAccountScopeFromWire,
234
235
  isEngineAccountLabel,
236
+ isEngineConnectKind,
235
237
  } from "./protocol";
236
238
 
237
239
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
@@ -615,6 +617,7 @@ function buildCapabilities(): HostCapabilities {
615
617
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
616
618
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
617
619
  HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
620
+ HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
618
621
  // The echo adapter cannot execute the in-task CLI. Advertising typed
619
622
  // dispatch in mock mode would let the composer create a Secretary that
620
623
  // has no way to wake crew.
@@ -2465,6 +2468,16 @@ async function dispatchCommand(
2465
2468
  if (reordered.ok) sendCapabilities();
2466
2469
  return reordered;
2467
2470
  }
2471
+ case "engineDisconnect": {
2472
+ const value = args[0];
2473
+ const engine = (value as { engine?: unknown } | undefined)?.engine;
2474
+ if (!isEngineConnectKind(engine)) {
2475
+ throw new Error("invalid command args: expected an engine kind at 0");
2476
+ }
2477
+ const result = await hostCommands.engineDisconnect(ctx, { engine });
2478
+ if (result.ok) sendCapabilities();
2479
+ return result;
2480
+ }
2468
2481
  }
2469
2482
  }
2470
2483
 
@@ -3050,6 +3063,7 @@ const HOST_COMMAND_NAMES = {
3050
3063
  engineAccountRemove: true,
3051
3064
  engineAccountRename: true,
3052
3065
  engineAccountReorder: true,
3066
+ engineDisconnect: true,
3053
3067
  } satisfies Record<keyof HostCommands, true>;
3054
3068
 
3055
3069
  function isHostCommand(command: string): command is keyof HostCommands {
package/src/protocol.ts CHANGED
@@ -113,6 +113,9 @@ export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
113
113
  * `engineAccountReorder` commands; login `succeeded` events carry the created
114
114
  * `accountId` for labeled captures; codex `awaiting_confirmation` carries the
115
115
  * device `code`. Extras are advertised in priority order (position). */
116
+ /** Host 0.9.107: `engineDisconnect` — forget the HOST's own credential for an
117
+ * engine (the default slot: .env.local key / login file). Owner-only. */
118
+ export const HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE = "host-engine-disconnect-v1";
116
119
  export const HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE =
117
120
  "host-engine-account-manage-v1";
118
121
  export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
@@ -1493,6 +1496,13 @@ export interface HostCommands {
1493
1496
  ctx: CommandContext,
1494
1497
  input: { kind: string; ids: string[]; scope?: string },
1495
1498
  ): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
1499
+ /** Disconnect-v1: forget the host's DEFAULT credential for an engine — the
1500
+ * .env.local key and/or the login file the adapter detects. Extras are
1501
+ * untouched (remove them by id). Re-advertises on success. */
1502
+ engineDisconnect(
1503
+ ctx: CommandContext,
1504
+ input: { engine: EngineConnectKind },
1505
+ ): Promise<HostCommandResult<{ disconnected: boolean; message?: string }>>;
1496
1506
  cloneRepo(
1497
1507
  ctx: CommandContext,
1498
1508
  input: CloneRepoInput,