impel-cli 0.20.2 → 0.20.4
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/README.md +61 -68
- package/RELEASE_NOTES.md +13 -0
- package/package.json +2 -1
- package/src/agents.js +94 -13
- package/src/apps.js +26 -2
- package/src/cli.js +2 -1
- package/src/commands/launch.js +11 -2
- package/src/commands/mcp.js +30 -8
- package/src/commands/remote.js +430 -26
- package/src/directAnswer.js +44 -0
- package/src/nativeAgentTransport.js +95 -7
- package/src/remote/aws.js +38 -0
- package/src/remote/broker.js +296 -0
- package/src/remote/checkpoint.js +248 -0
- package/src/remote/contracts.js +89 -0
- package/src/remote/state.js +13 -1
- package/src/remote/telemetry.js +29 -0
- package/src/remote/transfer.js +14 -6
- package/src/remote/workspace.js +482 -0
- package/src/selfInvocation.js +6 -2
- package/src/verbatimRelay.js +64 -0
package/src/commands/remote.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
2
5
|
|
|
3
6
|
import { parseFlags } from "../args.js";
|
|
4
7
|
import {
|
|
@@ -19,11 +22,18 @@ import {
|
|
|
19
22
|
awsContext,
|
|
20
23
|
describeTask,
|
|
21
24
|
readStack,
|
|
25
|
+
startBrokerSshProxy,
|
|
22
26
|
startSshProxy,
|
|
23
27
|
startTask,
|
|
24
28
|
stopTask,
|
|
25
29
|
waitForTaskReady,
|
|
26
30
|
} from "../remote/aws.js";
|
|
31
|
+
import { isSessionsUUID, RemoteBrokerClient, newMutationKey } from "../remote/broker.js";
|
|
32
|
+
import { prepareProviderCheckpoint, exactCheckpointRequiredMessage } from "../remote/checkpoint.js";
|
|
33
|
+
import {
|
|
34
|
+
assertRemoteCommandContract,
|
|
35
|
+
capabilityFailureMessage,
|
|
36
|
+
} from "../remote/contracts.js";
|
|
27
37
|
import { requireExecutables, runCapture, runInteractive } from "../remote/process.js";
|
|
28
38
|
import {
|
|
29
39
|
initializeRun,
|
|
@@ -32,6 +42,7 @@ import {
|
|
|
32
42
|
newRunId,
|
|
33
43
|
readRunState,
|
|
34
44
|
removeRunSecrets,
|
|
45
|
+
removeTransferArtifacts,
|
|
35
46
|
removeSshAlias,
|
|
36
47
|
resolveRunId,
|
|
37
48
|
runPaths,
|
|
@@ -47,51 +58,74 @@ import {
|
|
|
47
58
|
uploadRepository,
|
|
48
59
|
waitForSsh,
|
|
49
60
|
} from "../remote/transfer.js";
|
|
61
|
+
import { recordPortableLegacyInvocation } from "../remote/telemetry.js";
|
|
62
|
+
import { createWorkspaceArtifact } from "../remote/workspace.js";
|
|
50
63
|
|
|
51
64
|
const DEFAULT_REGION = "eu-west-2";
|
|
52
65
|
const DEFAULT_STACK = "impel-remote-dev";
|
|
53
66
|
const DEFAULT_TTL_SECONDS = 3600;
|
|
54
67
|
const DEFAULT_STARTUP_TIMEOUT_SECONDS = 300;
|
|
55
68
|
const PROVIDERS = new Set(["claude", "codex"]);
|
|
69
|
+
const LEGACY_MODE = "portable-legacy";
|
|
70
|
+
const LEGACY_FLAG = "IMPEL_REMOTE_ENABLE_PORTABLE_LEGACY";
|
|
56
71
|
|
|
57
72
|
const HELP = `impel remote - run an Impel session on an isolated AWS Fargate runner
|
|
58
73
|
|
|
59
74
|
Usage:
|
|
60
|
-
impel remote
|
|
75
|
+
impel remote dispatch [path] --provider codex|claude --mode fork|portable --session <sessions-uuid>
|
|
76
|
+
impel remote handoff [path] --provider codex --mode native --session <sessions-uuid>
|
|
61
77
|
impel remote status [run-id] [--json]
|
|
78
|
+
impel remote viewer [run-id] [--json]
|
|
79
|
+
impel remote proxy <run-id> --port <port>
|
|
80
|
+
impel remote handback [run-id] --yes [--abandon]
|
|
81
|
+
|
|
82
|
+
Legacy rollback commands (require ${LEGACY_FLAG}=1; removal scheduled for 2026-10-31):
|
|
83
|
+
impel remote up [path] --mode portable-legacy [options]
|
|
62
84
|
impel remote attach [run-id] [--provider codex|claude] [--desktop]
|
|
63
|
-
impel remote dispatch [path] --provider codex|claude --session <id> [--fork]
|
|
64
|
-
impel remote handoff [path] --provider codex|claude --session <id> [--detach]
|
|
65
85
|
impel remote follow [run-id]
|
|
66
|
-
impel remote proxy <run-id> --port <port>
|
|
67
86
|
impel remote down [run-id] --yes
|
|
68
87
|
impel remote down --all --yes
|
|
69
88
|
|
|
70
|
-
|
|
89
|
+
Broker lifecycle options:
|
|
71
90
|
--provider <name> Provider prepared for the run. Default: codex.
|
|
72
91
|
--ttl <seconds> Task lifetime from 300 through 86400. Default: 3600.
|
|
92
|
+
--timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
|
|
93
|
+
--json Emit machine-readable state where supported.
|
|
94
|
+
--mode <mode> Public modes: native, fork, or portable.
|
|
95
|
+
--source <surface> Explicit source: desktop, cli, or app-server. Default: cli.
|
|
96
|
+
--broker <url> Remote broker origin. Defaults to config remoteUrl/appUrl.
|
|
97
|
+
--checkpoint <path> Closed exact provider archive for fork.
|
|
98
|
+
--checkpoint-manifest <path> Closed capture manifest; CLI binds it to the broker run.
|
|
99
|
+
--session <uuid> Required Sessions binding UUID for every brokered run.
|
|
100
|
+
This is not a provider-native thread/session ID.
|
|
101
|
+
|
|
102
|
+
Legacy rollback options:
|
|
73
103
|
--region <region> AWS region. Default: eu-west-2.
|
|
74
104
|
--profile <profile> AWS CLI profile. Defaults to AWS_PROFILE/current AWS config.
|
|
75
105
|
--stack <name> CloudFormation stack. Default: impel-remote-dev.
|
|
76
106
|
--install auto|skip Restore recognized lockfile dependencies. Default: auto.
|
|
77
107
|
--setup <command> Additional explicit setup command to run after transfer.
|
|
78
108
|
--env <csv> Explicit environment variable names to copy; none by default.
|
|
79
|
-
--
|
|
80
|
-
--json Emit machine-readable state where supported.
|
|
109
|
+
--detach Start the legacy remote worker and return immediately.
|
|
81
110
|
|
|
82
|
-
|
|
83
|
-
--
|
|
111
|
+
Migration rollback (temporary; removal scheduled for 2026-10-31):
|
|
112
|
+
--mode portable-legacy Use the former one-shot worker only when
|
|
113
|
+
${LEGACY_FLAG}=1 is also set.
|
|
84
114
|
|
|
85
115
|
Attach options:
|
|
86
|
-
--session <id>
|
|
116
|
+
--session <id> Legacy attach: resume a transferred provider session.
|
|
87
117
|
--fork Fork instead of continuing the transferred session.
|
|
88
118
|
--desktop Open the native desktop connection flow instead of a terminal.
|
|
89
119
|
|
|
120
|
+
Native availability:
|
|
121
|
+
Codex Desktop and its SSH tunnel must remain awake and online for ChatGPT Remote.
|
|
122
|
+
Laptop-independent control requires Codex App Server or Claude through the Impel viewer.
|
|
123
|
+
|
|
90
124
|
The live UI uses native SSH: Codex Desktop starts remote codex app-server and can
|
|
91
125
|
hand off an existing chat and Git state. Claude Desktop can start an SSH session;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
gateway-backed Claude has no native arbitrary-host handoff. The former one-shot
|
|
127
|
+
worker is not a handoff and is available only as the explicitly flagged,
|
|
128
|
+
short-lived portable-legacy rollback mode.
|
|
95
129
|
Credentials, ignored files, SSH agents, and environment variables are never
|
|
96
130
|
copied implicitly.
|
|
97
131
|
`;
|
|
@@ -118,6 +152,15 @@ function providerFlag(value, fallback = "codex") {
|
|
|
118
152
|
return provider;
|
|
119
153
|
}
|
|
120
154
|
|
|
155
|
+
function sessionsUUIDFlag(value, action) {
|
|
156
|
+
if (!value) fail(`impel remote ${action}: --session <sessions-uuid> is required for every brokered run`);
|
|
157
|
+
const sessionId = String(value);
|
|
158
|
+
if (!isSessionsUUID(sessionId)) {
|
|
159
|
+
fail(`impel remote ${action}: --session must be a lowercase Sessions UUID, not a provider thread/session ID`);
|
|
160
|
+
}
|
|
161
|
+
return sessionId;
|
|
162
|
+
}
|
|
163
|
+
|
|
121
164
|
function rejectFlags(flags, allowed, action) {
|
|
122
165
|
const unsupported = Object.keys(flags).find((name) => !allowed.has(name));
|
|
123
166
|
if (unsupported) fail(`impel remote ${action}: unknown option --${unsupported}`);
|
|
@@ -130,16 +173,22 @@ function rejectMissingFlagValues(flags, spec, action) {
|
|
|
130
173
|
|
|
131
174
|
function lifecycleSpec() {
|
|
132
175
|
return {
|
|
176
|
+
broker: { type: "string" },
|
|
177
|
+
checkpoint: { type: "string" },
|
|
178
|
+
"checkpoint-manifest": { type: "string" },
|
|
179
|
+
"desktop-confirmed": { type: "boolean" },
|
|
133
180
|
env: { type: "string" },
|
|
134
181
|
detach: { type: "boolean" },
|
|
135
182
|
help: { type: "boolean" },
|
|
136
183
|
install: { type: "string" },
|
|
137
184
|
json: { type: "boolean" },
|
|
185
|
+
mode: { type: "string" },
|
|
138
186
|
profile: { type: "string" },
|
|
139
187
|
provider: { type: "string" },
|
|
140
188
|
region: { type: "string" },
|
|
141
189
|
session: { type: "string" },
|
|
142
190
|
setup: { type: "string" },
|
|
191
|
+
source: { type: "string" },
|
|
143
192
|
stack: { type: "string" },
|
|
144
193
|
timeout: { type: "string" },
|
|
145
194
|
ttl: { type: "string" },
|
|
@@ -147,6 +196,258 @@ function lifecycleSpec() {
|
|
|
147
196
|
};
|
|
148
197
|
}
|
|
149
198
|
|
|
199
|
+
function brokerClient(config, brokerOverride) {
|
|
200
|
+
return new RemoteBrokerClient({
|
|
201
|
+
baseUrl: normalizeGatewayUrl(brokerOverride || config.remoteUrl || config.appUrl || resolveDefaultAppUrl()),
|
|
202
|
+
pat: config.pat,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function brokerRunState(remote, prior = {}) {
|
|
207
|
+
const remoteState = remote.state || remote.status;
|
|
208
|
+
return {
|
|
209
|
+
...prior,
|
|
210
|
+
runId: remote.runId,
|
|
211
|
+
backend: "broker",
|
|
212
|
+
alias: prior.alias || sshAlias(remote.runId),
|
|
213
|
+
status: remoteState,
|
|
214
|
+
provider: remote.provider || prior.provider,
|
|
215
|
+
mode: remote.mode || prior.mode,
|
|
216
|
+
sourceSurface: remote.sourceSurface || prior.sourceSurface,
|
|
217
|
+
session: remote.sessionId ? { provider: remote.provider || prior.provider, id: remote.sessionId } : prior.session || null,
|
|
218
|
+
createdAt: remote.createdAt || prior.createdAt,
|
|
219
|
+
expiresAt: remote.expiresAt || prior.expiresAt,
|
|
220
|
+
brokerUrl: prior.brokerUrl,
|
|
221
|
+
repository: prior.repository ? {
|
|
222
|
+
head: prior.repository.head,
|
|
223
|
+
branch: prior.repository.branch,
|
|
224
|
+
relativeProjectPath: prior.repository.relativeProjectPath,
|
|
225
|
+
remoteProjectPath: prior.repository.remoteProjectPath,
|
|
226
|
+
} : null,
|
|
227
|
+
remote: {
|
|
228
|
+
state: remoteState,
|
|
229
|
+
version: remote.version ?? null,
|
|
230
|
+
workspaceClosed: typeof remote.workspace?.closedAt === "string"
|
|
231
|
+
&& Date.parse(remote.workspace.closedAt) > 0,
|
|
232
|
+
inputCheckpointClosed: typeof remote.inputCheckpoint?.closedAt === "string"
|
|
233
|
+
&& Date.parse(remote.inputCheckpoint.closedAt) > 0,
|
|
234
|
+
taskState: remote.task?.state || null,
|
|
235
|
+
connectionState: remote.connection?.state || null,
|
|
236
|
+
reasonCode: remote.stateReason || remote.reasonCode || null,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function persistBrokerRun(remote, prior) {
|
|
242
|
+
return writeRunState(brokerRunState(remote, prior));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sourceSurfaceFlag(value) {
|
|
246
|
+
return value || process.env.IMPEL_REMOTE_SOURCE_SURFACE || "cli";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function terminalBrokerState(state) {
|
|
250
|
+
return new Set(["stopped", "failed", "expired", "cancelled"]).has(state);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function waitForDesktopConfirmation(flags) {
|
|
254
|
+
if (flags["desktop-confirmed"] === true) return;
|
|
255
|
+
console.log("Codex CLI native handoff first requires the same saved session in Desktop. Run `/app`, confirm that Desktop opened this chat, then continue.");
|
|
256
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
257
|
+
fail("impel remote handoff: Desktop confirmation is required; complete `/app`, then rerun with --source desktop (or --desktop-confirmed from a trusted interactive wrapper)");
|
|
258
|
+
}
|
|
259
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
260
|
+
try { await prompt.question("Press Enter after Desktop shows the same chat: "); }
|
|
261
|
+
finally { prompt.close(); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function negotiateCapability(client, contract, flags) {
|
|
265
|
+
let current = contract;
|
|
266
|
+
let capability = await client.capability(current);
|
|
267
|
+
if (
|
|
268
|
+
current.provider === "codex" && current.mode === "native" && current.sourceSurface === "cli"
|
|
269
|
+
&& !capability.supported && capability.reasonCode === "DESKTOP_HANDOFF_REQUIRED"
|
|
270
|
+
) {
|
|
271
|
+
await waitForDesktopConfirmation(flags);
|
|
272
|
+
current = { ...current, sourceSurface: "desktop" };
|
|
273
|
+
capability = await client.capability(current);
|
|
274
|
+
}
|
|
275
|
+
if (!capability.supported) fail(`impel remote ${contract.action}: ${capabilityFailureMessage(capability)} No fallback was selected.`);
|
|
276
|
+
return { contract: current, capability };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function waitForBrokerRun(client, state, timeoutSeconds, { json = false } = {}) {
|
|
280
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
281
|
+
let lastState = state.status;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
const remote = await client.getRun(state.runId);
|
|
284
|
+
state = persistBrokerRun(remote, state);
|
|
285
|
+
if (!json && state.status !== lastState) console.log(`Remote run ${state.runId}: ${state.status}`);
|
|
286
|
+
lastState = state.status;
|
|
287
|
+
if (new Set(["ready", "running"]).has(state.status)) return state;
|
|
288
|
+
if (terminalBrokerState(state.status)) {
|
|
289
|
+
throw new Error(`remote run ${state.runId} became ${state.status}${state.remote.reasonCode ? ` (${state.remote.reasonCode})` : ""}`);
|
|
290
|
+
}
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, Number(process.env.IMPEL_REMOTE_BROKER_POLL_MS || 1000)));
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`remote run ${state.runId} did not become ready within ${timeoutSeconds} seconds`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function generateNativeSshKey() {
|
|
297
|
+
requireExecutables(["ssh-keygen", "session-manager-plugin"]);
|
|
298
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "impel-native-ssh-"));
|
|
299
|
+
const privateKey = path.join(root, "id_ed25519");
|
|
300
|
+
runCapture(process.env.IMPEL_REMOTE_SSH_KEYGEN_BIN || "ssh-keygen", [
|
|
301
|
+
"-q", "-t", "ed25519", "-N", "", "-C", "impel-remote-native", "-f", privateKey,
|
|
302
|
+
]);
|
|
303
|
+
const publicKey = fs.readFileSync(`${privateKey}.pub`, "utf8").trim();
|
|
304
|
+
if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}(?: [^\r\n]+)?$/u.test(publicKey)) {
|
|
305
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
306
|
+
throw new Error("ssh-keygen returned an invalid Ed25519 public key");
|
|
307
|
+
}
|
|
308
|
+
return { root, privateKey, publicKey: `${publicKey}\n` };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function retainNativeSshKey(generated, runId) {
|
|
312
|
+
const paths = initializeRun(runId);
|
|
313
|
+
fs.renameSync(generated.privateKey, paths.privateKey);
|
|
314
|
+
fs.renameSync(`${generated.privateKey}.pub`, paths.publicKey);
|
|
315
|
+
fs.writeFileSync(paths.knownHosts, "", { mode: 0o600 });
|
|
316
|
+
fs.rmSync(generated.root, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function createBrokerRun(argv, action) {
|
|
320
|
+
const spec = lifecycleSpec();
|
|
321
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
322
|
+
rejectFlags(flags, new Set([
|
|
323
|
+
"broker", "checkpoint", "checkpoint-manifest", "desktop-confirmed", "help", "json", "mode", "provider", "session", "source", "timeout", "ttl",
|
|
324
|
+
]), action);
|
|
325
|
+
rejectMissingFlagValues(flags, spec, action);
|
|
326
|
+
if (flags.help) { console.log(HELP); return null; }
|
|
327
|
+
if (positionals.length > 1) fail(`impel remote ${action}: expected at most one repository path`);
|
|
328
|
+
if (!flags.mode) {
|
|
329
|
+
fail(`impel remote ${action}: --mode is required; no legacy or fallback mode is selected implicitly`);
|
|
330
|
+
}
|
|
331
|
+
const contract = assertRemoteCommandContract({
|
|
332
|
+
action,
|
|
333
|
+
provider: providerFlag(flags.provider),
|
|
334
|
+
mode: flags.mode,
|
|
335
|
+
sourceSurface: sourceSurfaceFlag(flags.source),
|
|
336
|
+
});
|
|
337
|
+
const sessionId = sessionsUUIDFlag(flags.session, action);
|
|
338
|
+
if ((flags.checkpoint && !flags["checkpoint-manifest"]) || (!flags.checkpoint && flags["checkpoint-manifest"])) {
|
|
339
|
+
fail("impel remote dispatch: --checkpoint and --checkpoint-manifest must be provided together");
|
|
340
|
+
}
|
|
341
|
+
if (contract.mode === "fork" && !flags.checkpoint) fail(`impel remote dispatch: ${exactCheckpointRequiredMessage(contract.provider)}`);
|
|
342
|
+
if (contract.mode !== "fork" && flags.checkpoint) fail("impel remote: provider checkpoints are accepted only for --mode fork");
|
|
343
|
+
const config = requiredConfig();
|
|
344
|
+
const client = brokerClient(config, flags.broker);
|
|
345
|
+
const negotiated = await negotiateCapability(client, contract, flags);
|
|
346
|
+
const selected = await ensureTenantSelection(config, { refresh: !Array.isArray(config.scopes) });
|
|
347
|
+
assertProviderScopes(selected.scopes, [contract.provider], { requireLive: true });
|
|
348
|
+
const repository = inspectRepository(positionals[0] || process.cwd());
|
|
349
|
+
const ttlSeconds = integerFlag(flags.ttl, "ttl", DEFAULT_TTL_SECONDS, 300, 86400);
|
|
350
|
+
const timeoutSeconds = integerFlag(flags.timeout, "timeout", DEFAULT_STARTUP_TIMEOUT_SECONDS, 30, 900);
|
|
351
|
+
let generatedKey = null;
|
|
352
|
+
let state = null;
|
|
353
|
+
let cancelled = false;
|
|
354
|
+
let cancellation = null;
|
|
355
|
+
try {
|
|
356
|
+
if (negotiated.contract.mode === "native") generatedKey = generateNativeSshKey();
|
|
357
|
+
const remote = await client.createRun({
|
|
358
|
+
provider: negotiated.contract.provider,
|
|
359
|
+
mode: negotiated.contract.mode,
|
|
360
|
+
sourceSurface: negotiated.contract.sourceSurface,
|
|
361
|
+
sessionId,
|
|
362
|
+
...(repository.relativeProjectPath !== "." ? { repositorySubdir: repository.relativeProjectPath } : {}),
|
|
363
|
+
ttlSeconds,
|
|
364
|
+
...(generatedKey ? { sshPublicKey: generatedKey.publicKey } : {}),
|
|
365
|
+
}, newMutationKey("create"));
|
|
366
|
+
initializeRun(remote.runId);
|
|
367
|
+
if (generatedKey) {
|
|
368
|
+
retainNativeSshKey(generatedKey, remote.runId);
|
|
369
|
+
generatedKey = null;
|
|
370
|
+
}
|
|
371
|
+
state = persistBrokerRun(remote, {
|
|
372
|
+
runId: remote.runId,
|
|
373
|
+
backend: "broker",
|
|
374
|
+
brokerUrl: client.baseUrl,
|
|
375
|
+
provider: negotiated.contract.provider,
|
|
376
|
+
mode: negotiated.contract.mode,
|
|
377
|
+
sourceSurface: negotiated.contract.sourceSurface,
|
|
378
|
+
repository,
|
|
379
|
+
createdAt: new Date().toISOString(),
|
|
380
|
+
});
|
|
381
|
+
const onInterrupt = () => {
|
|
382
|
+
cancelled = true;
|
|
383
|
+
cancellation ||= client.cancelRun(state.runId, { reason: "client_interrupt" }, newMutationKey("sigint-cancel"))
|
|
384
|
+
.then((value) => { state = persistBrokerRun(value, state); })
|
|
385
|
+
.catch(() => {});
|
|
386
|
+
};
|
|
387
|
+
process.once("SIGINT", onInterrupt);
|
|
388
|
+
try {
|
|
389
|
+
if (negotiated.contract.mode !== "native") {
|
|
390
|
+
if (!flags.json) console.log("Capturing immutable workspace state…");
|
|
391
|
+
const workspace = createWorkspaceArtifact(repository, runPaths(state.runId).workspaceRoot);
|
|
392
|
+
state = persistBrokerRun(await client.uploadWorkspace(state.runId, workspace), state);
|
|
393
|
+
if (cancelled) throw new Error("remote run cancelled after interrupt");
|
|
394
|
+
}
|
|
395
|
+
if (negotiated.contract.mode === "fork") {
|
|
396
|
+
const checkpoint = prepareProviderCheckpoint({
|
|
397
|
+
archivePath: path.resolve(flags.checkpoint),
|
|
398
|
+
manifestPath: path.resolve(flags["checkpoint-manifest"]),
|
|
399
|
+
outputPath: runPaths(state.runId).providerCheckpoint,
|
|
400
|
+
provider: negotiated.contract.provider,
|
|
401
|
+
mode: "fork",
|
|
402
|
+
runId: state.runId,
|
|
403
|
+
sessionId,
|
|
404
|
+
});
|
|
405
|
+
state = persistBrokerRun(await client.uploadProviderCheckpoint(state.runId, checkpoint), state);
|
|
406
|
+
}
|
|
407
|
+
if (cancelled) throw new Error("remote run cancelled after interrupt");
|
|
408
|
+
state = await waitForBrokerRun(client, state, timeoutSeconds, { json: flags.json === true });
|
|
409
|
+
if (cancelled) throw new Error("remote run cancelled after interrupt");
|
|
410
|
+
} finally {
|
|
411
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
412
|
+
if (cancellation) await cancellation;
|
|
413
|
+
}
|
|
414
|
+
if (negotiated.contract.mode === "native") installSshAlias(state);
|
|
415
|
+
removeTransferArtifacts(state.runId);
|
|
416
|
+
printRun(state, flags.json === true);
|
|
417
|
+
if (!flags.json && negotiated.contract.mode === "native") {
|
|
418
|
+
console.log("");
|
|
419
|
+
console.log(`In Codex Desktop, add/open SSH host ${state.alias} and project ${state.repository.remoteProjectPath}.`);
|
|
420
|
+
console.log("Use the chat footer run-location picker to move this chat. Active generation will be interrupted.");
|
|
421
|
+
console.log("Desktop and this tunnel must stay awake/online for ChatGPT Remote. Detached control requires App Server plus the Impel viewer.");
|
|
422
|
+
console.log(`For handback, select “This computer” in Desktop, wait for the remote child to exit, then run: impel remote handback ${state.runId} --yes`);
|
|
423
|
+
}
|
|
424
|
+
return state;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (state && !terminalBrokerState(state.status)) {
|
|
427
|
+
try { state = persistBrokerRun(await client.cancelRun(state.runId, { reason: "client_setup_failed" }), state); } catch { /* Broker sweeper remains authoritative. */ }
|
|
428
|
+
}
|
|
429
|
+
if (state) {
|
|
430
|
+
removeSshAlias(state.runId);
|
|
431
|
+
removeRunSecrets(state.runId);
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
} finally {
|
|
435
|
+
if (generatedKey) fs.rmSync(generatedKey.root, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function requirePortableLegacy(flags, action) {
|
|
440
|
+
if (flags.mode !== LEGACY_MODE) {
|
|
441
|
+
const suffix = action === "handoff"
|
|
442
|
+
? "The old one-shot worker did not transfer a live session. Use `--mode native` for a supported native handoff, or `impel remote dispatch --mode fork|portable`."
|
|
443
|
+
: "The direct AWS runner is now a rollback-only path. Use a broker-backed `handoff --mode native` or `dispatch --mode fork|portable`.";
|
|
444
|
+
fail(`impel remote ${action}: ${suffix}`);
|
|
445
|
+
}
|
|
446
|
+
if (process.env[LEGACY_FLAG] !== "1") {
|
|
447
|
+
fail(`impel remote ${action}: portable-legacy is disabled; set ${LEGACY_FLAG}=1 only for the temporary rollback path (removal 2026-10-31)`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
150
451
|
function requiredConfig() {
|
|
151
452
|
const config = loadConfig();
|
|
152
453
|
if (!config?.pat) fail("impel remote: not authenticated; run `impel setup` first");
|
|
@@ -218,6 +519,8 @@ async function createRemoteRun(options) {
|
|
|
218
519
|
alias: sshAlias(runId),
|
|
219
520
|
status: "preparing",
|
|
220
521
|
provider,
|
|
522
|
+
mode: LEGACY_MODE,
|
|
523
|
+
legacyOnly: true,
|
|
221
524
|
createdAt,
|
|
222
525
|
expiresAt,
|
|
223
526
|
ttlSeconds,
|
|
@@ -227,6 +530,7 @@ async function createRemoteRun(options) {
|
|
|
227
530
|
let credential = null;
|
|
228
531
|
let taskStarted = false;
|
|
229
532
|
try {
|
|
533
|
+
recordPortableLegacyInvocation({ tenantId: selection.tenantId, runId });
|
|
230
534
|
runCapture(process.env.IMPEL_REMOTE_SSH_KEYGEN_BIN || "ssh-keygen", [
|
|
231
535
|
"-q", "-t", "ed25519", "-N", "", "-C", `impel-remote-${runId}`, "-f", paths.privateKey,
|
|
232
536
|
]);
|
|
@@ -342,10 +646,11 @@ async function cmdUp(argv, { dispatch = false } = {}) {
|
|
|
342
646
|
const spec = lifecycleSpec();
|
|
343
647
|
const { flags, positionals } = parseFlags(argv, spec);
|
|
344
648
|
rejectFlags(flags, new Set([
|
|
345
|
-
"env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl", "fork",
|
|
649
|
+
"env", "help", "install", "json", "mode", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl", "fork",
|
|
346
650
|
]), dispatch ? "dispatch" : "up");
|
|
347
651
|
rejectMissingFlagValues(flags, spec, dispatch ? "dispatch" : "up");
|
|
348
652
|
if (flags.help) { console.log(HELP); return null; }
|
|
653
|
+
requirePortableLegacy(flags, dispatch ? "dispatch" : "up");
|
|
349
654
|
if (positionals.length > 1) fail(`impel remote ${dispatch ? "dispatch" : "up"}: expected at most one repository path`);
|
|
350
655
|
if (!dispatch && (flags.session || flags.fork)) fail("impel remote up: --session and --fork are only supported by `impel remote dispatch`");
|
|
351
656
|
if (dispatch && !flags.session) fail("impel remote dispatch: --session is required");
|
|
@@ -417,19 +722,55 @@ async function cmdStatus(argv) {
|
|
|
417
722
|
if (flags.help) { console.log(HELP); return; }
|
|
418
723
|
if (positionals.length > 1) fail("impel remote status: expected at most one run id");
|
|
419
724
|
if (!positionals[0] && flags.json) {
|
|
420
|
-
const states =
|
|
421
|
-
|
|
422
|
-
|
|
725
|
+
const states = [];
|
|
726
|
+
const config = loadConfig();
|
|
727
|
+
for (const local of listRunStates()) {
|
|
728
|
+
try {
|
|
729
|
+
states.push(local.backend === "broker"
|
|
730
|
+
? persistBrokerRun(await brokerClient(config, local.brokerUrl).getRun(local.runId), local)
|
|
731
|
+
: syncTaskStatus(local));
|
|
732
|
+
} catch (error) {
|
|
733
|
+
states.push({ ...local, statusError: redactSecretText(error?.message || error) });
|
|
423
734
|
}
|
|
424
|
-
}
|
|
735
|
+
}
|
|
425
736
|
console.log(JSON.stringify(states, null, 2));
|
|
426
737
|
return;
|
|
427
738
|
}
|
|
428
739
|
const runId = resolveRunId(positionals[0], { includeStopped: true });
|
|
429
|
-
|
|
740
|
+
let state = readRunState(runId);
|
|
741
|
+
if (state.backend === "broker") {
|
|
742
|
+
const config = requiredConfig();
|
|
743
|
+
state = persistBrokerRun(await brokerClient(config, state.brokerUrl).getRun(runId), state);
|
|
744
|
+
} else {
|
|
745
|
+
state = syncTaskStatus(state);
|
|
746
|
+
}
|
|
430
747
|
printRun(state, flags.json === true);
|
|
431
748
|
}
|
|
432
749
|
|
|
750
|
+
async function cmdViewer(argv) {
|
|
751
|
+
const spec = { json: { type: "boolean" }, help: { type: "boolean" } };
|
|
752
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
753
|
+
rejectFlags(flags, new Set(["help", "json"]), "viewer");
|
|
754
|
+
if (flags.help) { console.log(HELP); return; }
|
|
755
|
+
if (positionals.length > 1) fail("impel remote viewer: expected at most one run id");
|
|
756
|
+
let state = readRunState(resolveRunId(positionals[0]));
|
|
757
|
+
if (state.backend !== "broker") fail("impel remote viewer: the legacy runner has no Impel viewer");
|
|
758
|
+
const config = requiredConfig();
|
|
759
|
+
const client = brokerClient(config, state.brokerUrl);
|
|
760
|
+
state = persistBrokerRun(await client.getRun(state.runId), state);
|
|
761
|
+
if (state.status !== "running") {
|
|
762
|
+
fail(`impel remote viewer: run ${state.runId} is ${state.status}, not running`);
|
|
763
|
+
}
|
|
764
|
+
const viewer = await client.createViewerLink(state.runId);
|
|
765
|
+
if (flags.json) {
|
|
766
|
+
console.log(JSON.stringify({ runId: state.runId, ...viewer }, null, 2));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
console.log(`Viewer link (one-time, expires ${viewer.expiresAt}):`);
|
|
770
|
+
console.log(viewer.url);
|
|
771
|
+
console.log("The exchange secret stays in the URL fragment and is removed by the viewer after exchange.");
|
|
772
|
+
}
|
|
773
|
+
|
|
433
774
|
function shellQuote(value) {
|
|
434
775
|
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
435
776
|
}
|
|
@@ -492,7 +833,7 @@ async function cmdAttach(argv) {
|
|
|
492
833
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
493
834
|
}
|
|
494
835
|
|
|
495
|
-
async function
|
|
836
|
+
async function cmdLegacyDispatch(argv) {
|
|
496
837
|
const state = await cmdUp(argv, { dispatch: true });
|
|
497
838
|
if (!state) return;
|
|
498
839
|
const { flags } = parseFlags(argv, lifecycleSpec());
|
|
@@ -561,14 +902,15 @@ async function followRemoteWorker(state) {
|
|
|
561
902
|
return { state: cleaned.state, completed: true };
|
|
562
903
|
}
|
|
563
904
|
|
|
564
|
-
async function
|
|
905
|
+
async function cmdLegacyHandoff(argv) {
|
|
565
906
|
const spec = lifecycleSpec();
|
|
566
907
|
const { flags, positionals } = parseFlags(argv, spec);
|
|
567
908
|
rejectFlags(flags, new Set([
|
|
568
|
-
"detach", "env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
|
|
909
|
+
"detach", "env", "help", "install", "json", "mode", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
|
|
569
910
|
]), "handoff");
|
|
570
911
|
rejectMissingFlagValues(flags, spec, "handoff");
|
|
571
912
|
if (flags.help) { console.log(HELP); return; }
|
|
913
|
+
requirePortableLegacy(flags, "handoff");
|
|
572
914
|
if (positionals.length > 1) fail("impel remote handoff: expected at most one repository path");
|
|
573
915
|
if (!flags.session) fail("impel remote handoff: --session is required");
|
|
574
916
|
|
|
@@ -603,6 +945,18 @@ async function cmdHandoff(argv) {
|
|
|
603
945
|
await followRemoteWorker(state);
|
|
604
946
|
}
|
|
605
947
|
|
|
948
|
+
async function cmdDispatch(argv) {
|
|
949
|
+
const { flags } = parseFlags(argv, lifecycleSpec());
|
|
950
|
+
if (flags.mode === LEGACY_MODE) return cmdLegacyDispatch(argv);
|
|
951
|
+
return createBrokerRun(argv, "dispatch");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function cmdHandoff(argv) {
|
|
955
|
+
const { flags } = parseFlags(argv, lifecycleSpec());
|
|
956
|
+
if (flags.mode === LEGACY_MODE) return cmdLegacyHandoff(argv);
|
|
957
|
+
return createBrokerRun(argv, "handoff");
|
|
958
|
+
}
|
|
959
|
+
|
|
606
960
|
async function cmdFollow(argv) {
|
|
607
961
|
const spec = { help: { type: "boolean" } };
|
|
608
962
|
const { flags, positionals } = parseFlags(argv, spec);
|
|
@@ -631,13 +985,22 @@ async function cmdDown(argv) {
|
|
|
631
985
|
: [readRunState(resolveRunId(positionals[0], { includeStopped: true }))];
|
|
632
986
|
const config = loadConfig();
|
|
633
987
|
for (let state of states) {
|
|
988
|
+
if (state.backend === "broker") {
|
|
989
|
+
if (!config?.pat) fail("impel remote down: not authenticated; run `impel setup` first");
|
|
990
|
+
const remote = await brokerClient(config, state.brokerUrl).stopRun(state.runId, { reason: "user_stop", abandon: true });
|
|
991
|
+
state = persistBrokerRun(remote, state);
|
|
992
|
+
removeSshAlias(state.runId);
|
|
993
|
+
if (terminalBrokerState(state.status)) removeRunSecrets(state.runId);
|
|
994
|
+
console.log(`Remote run ${state.runId} is ${state.status}; broker cleanup and credential revocation are authoritative.`);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
634
997
|
const cleaned = await stopAndCleanRun(config, state);
|
|
635
998
|
state = cleaned.state;
|
|
636
999
|
printCleanupResult(state, cleaned.revokeError);
|
|
637
1000
|
}
|
|
638
1001
|
}
|
|
639
1002
|
|
|
640
|
-
function cmdProxy(argv) {
|
|
1003
|
+
async function cmdProxy(argv) {
|
|
641
1004
|
const spec = {
|
|
642
1005
|
help: { type: "boolean" },
|
|
643
1006
|
port: { type: "string" },
|
|
@@ -650,13 +1013,52 @@ function cmdProxy(argv) {
|
|
|
650
1013
|
const port = integerFlag(flags.port, "port", 22, 1, 65535);
|
|
651
1014
|
if (port !== 22) fail("impel remote proxy: the runner only exposes SSH port 22");
|
|
652
1015
|
const state = readRunState(positionals[0]);
|
|
653
|
-
if (state.status === "stopped" || state.status === "failed") {
|
|
1016
|
+
if (state.backend === "broker" ? terminalBrokerState(state.status) : state.status === "stopped" || state.status === "failed") {
|
|
654
1017
|
fail(`impel remote proxy: run ${state.runId} is ${state.status}`);
|
|
655
1018
|
}
|
|
656
|
-
|
|
1019
|
+
let exitCode;
|
|
1020
|
+
if (state.backend === "broker") {
|
|
1021
|
+
const config = requiredConfig();
|
|
1022
|
+
const client = brokerClient(config, state.brokerUrl);
|
|
1023
|
+
const connection = await client.createConnection(state.runId);
|
|
1024
|
+
try {
|
|
1025
|
+
exitCode = startBrokerSshProxy(connection);
|
|
1026
|
+
} finally {
|
|
1027
|
+
try { await client.closeConnection(state.runId, connection.leaseId); }
|
|
1028
|
+
catch { exitCode ||= 1; }
|
|
1029
|
+
}
|
|
1030
|
+
} else {
|
|
1031
|
+
exitCode = startSshProxy(awsContext(state), state, port);
|
|
1032
|
+
}
|
|
657
1033
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
658
1034
|
}
|
|
659
1035
|
|
|
1036
|
+
async function cmdHandback(argv) {
|
|
1037
|
+
const spec = { abandon: { type: "boolean" }, help: { type: "boolean" }, yes: { type: "boolean" } };
|
|
1038
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
1039
|
+
rejectFlags(flags, new Set(["abandon", "help", "yes"]), "handback");
|
|
1040
|
+
if (flags.help) { console.log(HELP); return; }
|
|
1041
|
+
if (positionals.length > 1) fail("impel remote handback: expected at most one run id");
|
|
1042
|
+
if (flags.yes !== true) {
|
|
1043
|
+
fail("impel remote handback: first select “This computer” in Desktop and wait for the remote child to exit, then confirm with --yes");
|
|
1044
|
+
}
|
|
1045
|
+
let state = readRunState(resolveRunId(positionals[0], { includeStopped: true }));
|
|
1046
|
+
if (state.backend !== "broker" || state.mode !== "native") fail("impel remote handback: run is not a broker-backed native handoff");
|
|
1047
|
+
const config = requiredConfig();
|
|
1048
|
+
const client = brokerClient(config, state.brokerUrl);
|
|
1049
|
+
const remote = flags.abandon
|
|
1050
|
+
? await client.stopRun(state.runId, { reason: "user_abandon", abandon: true })
|
|
1051
|
+
: await client.handbackRun(state.runId);
|
|
1052
|
+
state = persistBrokerRun(remote, state);
|
|
1053
|
+
if (terminalBrokerState(state.status)) {
|
|
1054
|
+
removeSshAlias(state.runId);
|
|
1055
|
+
removeRunSecrets(state.runId);
|
|
1056
|
+
console.log(`Native run ${state.runId} is ${state.status}; its remote writer lease and credential are closed.`);
|
|
1057
|
+
} else {
|
|
1058
|
+
console.log(`Handback confirmation recorded for ${state.runId}; state is ${state.status}. The broker will stop only after the remote child/SSH signal also closes.`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
660
1062
|
export async function cmdRemote(argv) {
|
|
661
1063
|
const [action, ...rest] = argv;
|
|
662
1064
|
try {
|
|
@@ -669,12 +1071,14 @@ export async function cmdRemote(argv) {
|
|
|
669
1071
|
return;
|
|
670
1072
|
case "up": return await cmdUp(rest);
|
|
671
1073
|
case "status": return await cmdStatus(rest);
|
|
1074
|
+
case "viewer": return await cmdViewer(rest);
|
|
672
1075
|
case "attach": return await cmdAttach(rest);
|
|
673
1076
|
case "dispatch": return await cmdDispatch(rest);
|
|
674
1077
|
case "handoff": return await cmdHandoff(rest);
|
|
675
1078
|
case "follow": return await cmdFollow(rest);
|
|
676
1079
|
case "down": return await cmdDown(rest);
|
|
677
|
-
case "
|
|
1080
|
+
case "handback": return await cmdHandback(rest);
|
|
1081
|
+
case "proxy": return await cmdProxy(rest);
|
|
678
1082
|
default: fail(`impel remote: unknown subcommand ${JSON.stringify(action)}`);
|
|
679
1083
|
}
|
|
680
1084
|
} catch (error) {
|