impel-cli 0.20.37 → 0.20.39

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 CHANGED
@@ -180,24 +180,36 @@ impel claude --model opus
180
180
  impel codex exec "review this repository"
181
181
  ```
182
182
 
183
- To run one explicitly selected tenant agent in a single Codex thread, select
183
+ To run one explicitly selected tenant agent in a single native thread, select
184
184
  it by exact agent ID (preferred) or unique exact title:
185
185
 
186
186
  ```sh
187
+ impel claude --agent research-agent --print "answer this question"
187
188
  impel codex --agent research-agent exec "answer this question"
188
189
  ```
189
190
 
190
- This path loads a generated, tenant-bound profile with the ultra-fast Codex
191
- Spark model at low reasoning for the thin adapter turns, a read-only local
192
- sandbox, the exact fixed MCP binding, and only that binding's answer/run,
193
- resume, and recovery tools. It omits unrelated local repository, app,
194
- collaboration, permission, environment, and skill-catalog prompt context; the
195
- selected Eve agent still performs the substantive work. The path also omits
196
- the normal parent specialist instructions, so there is no parent spawn, parent
197
- wait, or relay turn. Model, profile, developer-instruction, MCP, approval,
198
- sandbox, and feature overrides are rejected with `--agent`. The selected
199
- tenant and profile integrity must match before Codex starts. Ordinary `impel
200
- codex` passthrough is unchanged.
191
+ Both managed paths verify the selected tenant manifest and generated adapter
192
+ bytes, resolve the canonical native agent name, and omit the normal parent
193
+ specialist instructions. Claude Code receives the explicit 2.1.220 opt-in and
194
+ session permission mode required to run only that adapter's fixed MCP
195
+ allowlist. Impel re-declares the verified adapter under a random per-launch
196
+ name and loads only the isolated user settings source, so project, user, plugin,
197
+ and fixed organization agent names cannot replace it. User-authored Claude
198
+ agents that are absent from the Impel manifest keep Claude's ordinary
199
+ permission behavior. Model, prompt, agent-definition, tool, MCP, settings,
200
+ plugin, worktree, and permission overrides are rejected for a verified managed
201
+ Claude selection.
202
+
203
+ Codex loads a generated, tenant-bound profile with the ultra-fast Codex Spark
204
+ model at low reasoning for the thin adapter turns, a read-only local sandbox,
205
+ the exact fixed MCP binding, and only that binding's answer/run, resume, and
206
+ recovery tools. It disables host plugin and remote-plugin loading and omits
207
+ unrelated local repository, app, collaboration, permission, environment, and
208
+ skill-catalog prompt context; the selected Eve agent still performs the
209
+ substantive work. Model, profile,
210
+ developer-instruction, MCP, approval, sandbox, and feature overrides are
211
+ rejected for its managed path. Ordinary `impel claude`, user-authored Claude
212
+ agent, and `impel codex` passthrough are unchanged.
201
213
 
202
214
  ## Remote Fargate sessions
203
215
 
@@ -518,6 +530,7 @@ must remain explicit and destructive-state aware.
518
530
  impel setup
519
531
  impel update
520
532
  impel claude
533
+ impel claude --agent <id|exact-title> ...
521
534
  impel codex
522
535
  impel codex --agent <id|exact-title> ...
523
536
  impel tenant list|current|use
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.39 — Isolate fixed Codex agent adapters
4
+
5
+ - Disables local and remote plugin loading, locally built plugin sharing, and
6
+ recommended-plugin discovery only in qualified fixed-binding Codex agent
7
+ profiles while preserving the direct Code Mode namespace and ordinary
8
+ `impel codex` plugin behavior.
9
+ - Advances the managed-agent manifest and generated-config versions so existing
10
+ managed installs receive the isolated adapter profile on update.
11
+
12
+ ## 0.20.38 — Restore managed Claude agent permissions
13
+
14
+ - Verifies an exact synchronized Claude agent and its generated file digest
15
+ before enabling Claude Code 2.1.220's required bypass-permission opt-in and
16
+ session mode for that fixed MCP-only adapter.
17
+ - Resolves exact agent IDs and titles to a byte-verified definition, declares
18
+ it under a random per-launch name with user-only setting sources, omits the
19
+ parent delegation prompt, and rejects model, prompt, tool, MCP, settings,
20
+ plugin, worktree, and permission overrides on the managed path.
21
+ - Leaves ordinary Claude launches, user-authored Claude agents, and every
22
+ Codex launch path unchanged.
23
+
3
24
  ## 0.20.37 — Resume ambiguous native answers
4
25
 
5
26
  - Retries Next's typed ambiguous direct-answer response with the exact persisted
package/bin/impel.js CHANGED
@@ -13,6 +13,15 @@ if (process.platform === "win32" && process.env.IMPEL_SKIP_ENTRYPOINT_REFRESH !=
13
13
  }
14
14
 
15
15
  main(process.argv.slice(2)).catch((err) => {
16
- console.error(`impel: ${err?.stack || err?.message || err}`);
16
+ // Intentional user-facing errors are thrown as bare `Error` with guidance in
17
+ // the message ("not authenticated; run `impel setup`…"); printing their stack
18
+ // buries the guidance in noise on end-user machines. Programming errors
19
+ // (TypeError, system errors with a code) keep the full stack, and
20
+ // IMPEL_DEBUG=1 restores it for everything.
21
+ const expected = err instanceof Error && err.constructor === Error && !err.code;
22
+ const detail = expected && process.env.IMPEL_DEBUG !== "1"
23
+ ? err.message
24
+ : (err?.stack || err?.message || err);
25
+ console.error(`impel: ${detail}`);
17
26
  process.exitCode = 1;
18
27
  });
@@ -1,12 +1,12 @@
1
1
  # Native-agent host capability matrix
2
2
 
3
- Validated on 2026-08-08 with isolated temporary homes and tenant profiles. The
3
+ Validated on 2026-08-09 with isolated temporary homes and tenant profiles. The
4
4
  matrix is intentionally pinned: a client upgrade must be re-qualified before it
5
5
  is treated as an eager host.
6
6
 
7
7
  | Surface | Pinned build | Exact selected tools | Selected path | Host behavior |
8
8
  | --- | --- | --- | --- | --- |
9
- | Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager | The bound run/answer, resume, and recovery tools are direct child tools. No catalog discovery or code-cell polling is required. |
9
+ | Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager; optional single-thread selection | The bound run/answer, resume, and recovery tools are direct child tools. `impel claude --agent` verifies the exact managed adapter before enabling 2.1.220's bypass-permission opt-in and mode. |
10
10
  | Claude Desktop | 1.26832.0 (embedded Code 2.1.222) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
11
11
  | Codex CLI through `impel codex` | 0.147.0 | Yes, with `features.code_mode.direct_only_tool_namespaces = ["mcp__impel_agent"]` | Eager; optional single-thread profile | Existing `@Agent` children call the fixed MCP namespace directly. `impel codex --agent` also removes the parent spawn/wait/relay thread. |
12
12
  | ChatGPT desktop Codex | 26.803.41515 (embedded Codex 0.147.0-alpha.6.5) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
@@ -16,6 +16,17 @@ is treated as an eager host.
16
16
  - Eager profiles expose only the exact fixed binding's answer/run, resume, and
17
17
  recovery tools. `IMPEL_NATIVE_EAGER_TRANSPORT=0` independently restores the
18
18
  legacy host-visible tool configuration without deleting profiles or runs.
19
+ - A top-level Claude selection receives `--allow-dangerously-skip-permissions`
20
+ plus `--permission-mode bypassPermissions` only after its manifest record,
21
+ canonical definition, fixed MCP binding, and generated file bytes have been
22
+ verified. User-authored agents keep native Claude behavior.
23
+ - The verified definition is passed through `--agents` under a random
24
+ per-launch name while `--setting-sources user` excludes project/local
25
+ definitions. This outranks user and plugin names and makes collisions with a
26
+ fixed organization-managed agent name impractical.
27
+ - Verified Claude selections reject model, prompt, agent-definition, tool,
28
+ MCP, settings, plugin, worktree, and permission-policy overrides before
29
+ native launch.
19
30
  - Every local composite keeps one upstream MCP session for its process lifetime,
20
31
  validates the exact binding once, and persists the authoritative run handle
21
32
  before ambiguous retries.
@@ -39,8 +50,9 @@ is treated as an eager host.
39
50
  `impel_agent` is not equivalent and does not qualify as direct exposure.
40
51
  - Generated Codex fixed-binding adapters use the ultra-fast Codex Spark model
41
52
  at low reasoning for the thin tool-selection and terminal-relay turns, with
42
- unrelated local host and skill-catalog context disabled. The selected Eve
43
- agent, not this wrapper model, still owns the substantive task.
53
+ unrelated local host, plugin, remote-plugin, and skill-catalog context
54
+ disabled. The selected Eve agent, not this wrapper model, still owns the
55
+ substantive task.
44
56
 
45
57
  ## Requalification
46
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.37",
3
+ "version": "0.20.39",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,56 @@
1
+ // Capture the released-state fixture corpus (docs/testing-protocol.md §5
2
+ // P0-2, Law 3: fixtures are captured from released artifacts, never
3
+ // fabricated from HEAD after the fact).
4
+ //
5
+ // Run this AT RELEASE TIME, on the exact tree being tagged, so
6
+ // test/fixtures/released/<version>/ honestly represents what shipped:
7
+ //
8
+ // node scripts/capture-release-fixtures.mjs [--force]
9
+ //
10
+ // It regenerates the managed on-disk artifacts deterministically and offline
11
+ // (see scripts/regen-managed-artifacts.mjs: fixed inputs, temp HOMEs, fake
12
+ // impel_pat_TEST credential, double-generation byte comparison) into
13
+ // test/fixtures/released/<package.json version>/. An existing version
14
+ // directory is never overwritten unless --force is passed.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { REPOSITORY_ROOT, regenerateManagedArtifacts } from "./regen-managed-artifacts.mjs";
21
+
22
+ export const RELEASED_FIXTURES_ROOT = path.join(REPOSITORY_ROOT, "test", "fixtures", "released");
23
+
24
+ export async function captureReleaseFixtures({ force = false } = {}) {
25
+ const packageJson = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, "package.json"), "utf8"));
26
+ const version = packageJson.version;
27
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
28
+ throw new Error(`package.json version "${version}" is not a valid release version`);
29
+ }
30
+ const outputDir = path.join(RELEASED_FIXTURES_ROOT, version);
31
+ if (fs.existsSync(outputDir) && !force) {
32
+ throw new Error(
33
+ `${outputDir} already exists. A released fixture is immutable once captured; `
34
+ + "pass --force only to re-capture a fixture that never shipped.",
35
+ );
36
+ }
37
+ const metadata = await regenerateManagedArtifacts(outputDir, { force });
38
+ return { version, outputDir, metadata };
39
+ }
40
+
41
+ const isMain = process.argv[1]
42
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
43
+
44
+ if (isMain) {
45
+ const { version, outputDir, metadata } = await captureReleaseFixtures({
46
+ force: process.argv.includes("--force"),
47
+ });
48
+ console.log(
49
+ `captured released-state fixtures for v${version}: ${metadata.artifacts.length} artifacts `
50
+ + `(configVersion ${metadata.configVersion}, ${metadata.bundleFingerprint})`,
51
+ );
52
+ console.log(` ${outputDir}`);
53
+ for (const excluded of metadata.excluded) {
54
+ console.log(` excluded: ${excluded.path}`);
55
+ }
56
+ }
@@ -0,0 +1,51 @@
1
+ # Clean-machine protocol (P0-4)
2
+
3
+ Runs the CLI the way a brand-new customer machine does — no git, no winget, no
4
+ dev tooling, stock PATH — because that machine state is structurally
5
+ unreachable from CI runners, and it is where an entire class of first-run
6
+ failures shipped (see `docs/testing-protocol.md`, RC2).
7
+
8
+ ## Windows leg
9
+
10
+ ```sh
11
+ AWS_PROFILE=useimpel scripts/clean-machine/run-clean-windows.sh
12
+ ```
13
+
14
+ Launches a disposable, self-terminating Windows Server EC2 instance (no
15
+ inbound access, no SSH key, S3-scoped instance role) that:
16
+
17
+ 1. asserts the machine is actually clean (git absent; winget state recorded);
18
+ 2. installs Node headless from the official MSI;
19
+ 3. global-installs the packed tarball with the bundled npm and asserts the
20
+ install is a real directory (the npm prepare-script symlink class);
21
+ 4. runs `impel --version`, `impel doctor`, and non-interactive `impel setup`
22
+ with hang guards, asserting actionable single-line failures — never a
23
+ stack trace, never a hang, never a silent success;
24
+ 5. runs the full ungated test suite on the git-less machine — the leg that
25
+ catches tests silently depending on runner tooling;
26
+ 6. uploads `results.json` + full logs to S3 and terminates itself.
27
+
28
+ The runner exits 0 only when every post-condition holds and the suite has no
29
+ failing test. First run of this harness found 18 environment-sensitive tests
30
+ and two CLI output defects; expect it to keep earning its keep.
31
+
32
+ ## When to run
33
+
34
+ - Before any stable release that touches install/update/setup paths
35
+ (protocol §4.3), and after changing anything under `src/installRecovery/`,
36
+ `src/selfInvocation.js`, or the skills/git provisioning chain.
37
+ - Cost: one t3.large for ~15 minutes (well under $0.10) plus a few MB of S3.
38
+
39
+ ## Known limitations
40
+
41
+ - Windows **Server** AMIs never ship winget, so the winget install path can't
42
+ be exercised here (EC2 offers no consumer Windows 11 images). That leg stays
43
+ covered by `test/launch.test.js` (cmd-escaping contract) and the MSIX/Store
44
+ contract workflow. The results record winget absence so the gap is visible.
45
+ - The instance runs as SYSTEM at first boot, not as an interactive standard
46
+ user; per-user ACL behavior is covered by the `windows-standard-user-contract`
47
+ CI job instead.
48
+ - macOS leg: EC2 Mac requires a 24-hour dedicated-host allocation, so the
49
+ macOS clean-machine equivalent (running vendor apps, name-colliding consumer
50
+ apps, stale Homebrew binaries) is not automated here yet; the launch-smoke
51
+ CI job covers the bundle/egress side on ephemeral macOS runners.
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # Clean-machine protocol (docs/testing-protocol.md P0-4), Windows leg runner.
3
+ #
4
+ # Launches a disposable, self-terminating Windows Server EC2 instance that has
5
+ # no git and no dev tooling, installs Node headless, global-installs the packed
6
+ # CLI tarball, asserts post-conditions (version/doctor/setup actionable, real
7
+ # install dir), runs the full ungated test suite, and uploads results to S3.
8
+ # The instance needs no inbound access and no SSH key; results come back via a
9
+ # bucket-scoped instance role.
10
+ #
11
+ # Usage:
12
+ # AWS_PROFILE=useimpel scripts/clean-machine/run-clean-windows.sh
13
+ #
14
+ # Tunables (env): AWS_PROFILE, CLEAN_MACHINE_REGION (default eu-west-2),
15
+ # CLEAN_MACHINE_BUCKET, CLEAN_MACHINE_INSTANCE_TYPE (default t3.large),
16
+ # CLEAN_MACHINE_NODE_VERSION (default v24.19.0).
17
+ #
18
+ # One-time account setup (idempotent, created automatically if missing):
19
+ # bucket, IAM role/instance-profile `clean-machine-test-role` (S3 Get/Put on
20
+ # the bucket only), and a no-inbound security group.
21
+ set -euo pipefail
22
+
23
+ REGION="${CLEAN_MACHINE_REGION:-eu-west-2}"
24
+ ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
25
+ BUCKET="${CLEAN_MACHINE_BUCKET:-impel-clean-machine-tests-${ACCOUNT}}"
26
+ INSTANCE_TYPE="${CLEAN_MACHINE_INSTANCE_TYPE:-t3.large}"
27
+ NODE_VERSION="${CLEAN_MACHINE_NODE_VERSION:-v24.19.0}"
28
+ ROLE="clean-machine-test-role"
29
+ SG_NAME="impel-clean-machine-no-inbound"
30
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
32
+ WORK="$(mktemp -d)"
33
+ trap 'rm -rf "$WORK"' EXIT
34
+ export AWS_DEFAULT_REGION="$REGION"
35
+
36
+ echo "== packing CLI + source from $REPO_ROOT"
37
+ (cd "$REPO_ROOT" && pnpm pack --pack-destination "$WORK" >/dev/null)
38
+ TARBALL="$(basename "$(find "$WORK" -maxdepth 1 -name 'impel-cli-*.tgz' -print -quit)")"
39
+ test -n "$TARBALL"
40
+ (cd "$REPO_ROOT" && git archive --format=tar.gz -o "$WORK/impel-cli-source.tgz" HEAD)
41
+
42
+ echo "== ensuring bucket/role/security group"
43
+ aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null \
44
+ || aws s3api create-bucket --bucket "$BUCKET" --create-bucket-configuration "LocationConstraint=$REGION" >/dev/null
45
+ aws iam get-role --role-name "$ROLE" >/dev/null 2>&1 || {
46
+ aws iam create-role --role-name "$ROLE" --assume-role-policy-document \
47
+ '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' >/dev/null
48
+ aws iam create-instance-profile --instance-profile-name "$ROLE" >/dev/null
49
+ aws iam add-role-to-instance-profile --instance-profile-name "$ROLE" --role-name "$ROLE"
50
+ sleep 10 # instance-profile eventual consistency
51
+ }
52
+ aws iam put-role-policy --role-name "$ROLE" --policy-name s3-scratch --policy-document \
53
+ "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":\"arn:aws:s3:::$BUCKET/*\"}]}"
54
+ VPC="$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)"
55
+ SG="$(aws ec2 describe-security-groups --filters "Name=group-name,Values=$SG_NAME" --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null)"
56
+ if [ "$SG" = "None" ] || [ -z "$SG" ]; then
57
+ SG="$(aws ec2 create-security-group --group-name "$SG_NAME" --description "No-inbound SG for impel clean-machine tests" --vpc-id "$VPC" --query GroupId --output text)"
58
+ fi
59
+ SUBNET="$(aws ec2 describe-subnets --filters Name=default-for-az,Values=true --query 'Subnets[0].SubnetId' --output text)"
60
+ AMI="$(aws ssm get-parameter --name /aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base --query 'Parameter.Value' --output text)"
61
+
62
+ echo "== uploading artifacts to s3://$BUCKET"
63
+ aws s3 rm "s3://$BUCKET/results" --recursive --only-show-errors 2>/dev/null || true
64
+ aws s3 cp "$WORK/$TARBALL" "s3://$BUCKET/$TARBALL" --only-show-errors
65
+ aws s3 cp "$WORK/impel-cli-source.tgz" "s3://$BUCKET/impel-cli-source.tgz" --only-show-errors
66
+
67
+ sed -e "s|__BUCKET__|$BUCKET|g" -e "s|__REGION__|$REGION|g" \
68
+ -e "s|__CLI_TARBALL__|$TARBALL|g" -e "s|__NODE_VERSION__|$NODE_VERSION|g" \
69
+ "$HERE/userdata.ps1.template" > "$WORK/userdata.ps1"
70
+
71
+ echo "== launching $INSTANCE_TYPE ($AMI) in $SUBNET"
72
+ INSTANCE="$(aws ec2 run-instances --image-id "$AMI" --instance-type "$INSTANCE_TYPE" \
73
+ --iam-instance-profile "Name=$ROLE" --instance-initiated-shutdown-behavior terminate \
74
+ --user-data "file://$WORK/userdata.ps1" --security-group-ids "$SG" --subnet-id "$SUBNET" \
75
+ --metadata-options HttpTokens=required \
76
+ --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=impel-clean-machine-test},{Key=purpose,Value=impel-cli-testing-protocol}]' \
77
+ --query 'Instances[0].InstanceId' --output text)"
78
+ echo "instance: $INSTANCE (self-terminates when done)"
79
+
80
+ echo "== waiting for results (boot + install + suite; typically 8-20 min)"
81
+ for i in $(seq 1 45); do
82
+ if aws s3api head-object --bucket "$BUCKET" --key results/results.json >/dev/null 2>&1; then
83
+ aws s3 cp "s3://$BUCKET/results/results.json" "$WORK/results.json" --only-show-errors
84
+ echo "== results after ~${i} min:"
85
+ node -e '
86
+ const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^/, ""));
87
+ const s = r.steps;
88
+ const fail = [];
89
+ if (s.preconditions.gitPresent) fail.push("machine not clean: git present");
90
+ if (s.nodeMsiExit !== 0) fail.push(`node MSI exit ${s.nodeMsiExit}`);
91
+ if (!s.impelCmdExists) fail.push("impel.cmd missing after global install");
92
+ if (!s.installIsRealDir) fail.push("global install is a reparse point (npm symlink class)");
93
+ if (s.version && !/^\d+\.\d+\.\d+/.test((s.version.stdoutHead || "").trim())) fail.push("impel --version malformed");
94
+ for (const k of ["doctor", "setupNoAuth"]) {
95
+ const st = s[k]; if (!st) continue;
96
+ if (st.timedOut) fail.push(`${k} hung`);
97
+ if (/\n\s+at /.test(st.stderrHead || "")) fail.push(`${k} printed a stack trace`);
98
+ }
99
+ const suite = s.testSuite || {};
100
+ const m = (suite.stdoutHead || "").match(/# fail (\d+)/) || (suite.stdoutHead || "").match(/✖/);
101
+ if (suite.timedOut) fail.push("test suite hung");
102
+ if ((suite.stdoutHead || "").includes("✖")) fail.push("test suite has failing tests (see results/node-test.out.txt)");
103
+ console.log(JSON.stringify({ preconditions: s.preconditions, node: (s.nodeVersion||"").trim(), npm: (s.npmVersion||"").trim(), verdict: fail.length ? "FAIL" : "PASS", failures: fail }, null, 2));
104
+ process.exitCode = fail.length ? 1 : 0;
105
+ ' "$WORK/results.json" || { echo "full logs: s3://$BUCKET/results/"; exit 1; }
106
+ echo "full logs: s3://$BUCKET/results/"
107
+ exit 0
108
+ fi
109
+ STATE="$(aws ec2 describe-instances --instance-ids "$INSTANCE" --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo unknown)"
110
+ if [ "$STATE" = "terminated" ]; then echo "instance terminated without uploading results"; exit 1; fi
111
+ sleep 60
112
+ done
113
+ echo "timed out waiting for results; terminating $INSTANCE"
114
+ aws ec2 terminate-instances --instance-ids "$INSTANCE" >/dev/null
115
+ exit 1
@@ -0,0 +1,102 @@
1
+ <powershell>
2
+ # Clean-machine protocol (docs/testing-protocol.md P0-4), Windows leg.
3
+ # Rendered by run-clean-windows.sh: __BUCKET__, __REGION__, __CLI_TARBALL__,
4
+ # __NODE_VERSION__ are substituted before launch. Runs at first boot as SYSTEM
5
+ # on a fresh Windows Server AMI (no git, no dev tooling), self-terminates.
6
+ $ErrorActionPreference = 'Continue'
7
+ $root = 'C:\clean-machine'
8
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
9
+ Start-Transcript -Path "$root\transcript.txt" -Force
10
+ $bucket = '__BUCKET__'
11
+ $region = '__REGION__'
12
+ $results = [ordered]@{ startedAt = (Get-Date).ToUniversalTime().ToString('o'); steps = [ordered]@{} }
13
+
14
+ function Run-Step {
15
+ param([string]$name, [string]$file, [string[]]$argList, [int]$timeoutSec = 180, [string]$stdin = $null)
16
+ $out = "$root\$name.out.txt"; $err = "$root\$name.err.txt"
17
+ $procArgs = @{ FilePath = $file; ArgumentList = $argList; RedirectStandardOutput = $out; RedirectStandardError = $err; NoNewWindow = $true; PassThru = $true }
18
+ if ($null -ne $stdin) { $inFile = "$root\$name.in.txt"; Set-Content -Path $inFile -Value $stdin; $procArgs.RedirectStandardInput = $inFile }
19
+ $step = [ordered]@{ timedOut = $false; exitCode = $null; stdoutHead = ''; stderrHead = '' }
20
+ try {
21
+ $p = Start-Process @procArgs
22
+ if (-not $p.WaitForExit($timeoutSec * 1000)) {
23
+ $step.timedOut = $true
24
+ Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
25
+ Start-Sleep -Seconds 2
26
+ }
27
+ $step.exitCode = $p.ExitCode
28
+ } catch { $step.error = "$_" }
29
+ foreach ($pair in @(@('stdoutHead', $out, 3000), @('stderrHead', $err, 2000))) {
30
+ $key = $pair[0]; $file2 = $pair[1]; $keep = $pair[2]
31
+ if (Test-Path $file2) {
32
+ $text = Get-Content $file2 -Raw -ErrorAction SilentlyContinue
33
+ if ($null -eq $text) { $text = '' }
34
+ if ($text.Length -gt (2 * $keep)) { $text = $text.Substring(0, $keep) + "`n...TRUNCATED...`n" + $text.Substring($text.Length - $keep) }
35
+ $step[$key] = $text
36
+ }
37
+ }
38
+ return $step
39
+ }
40
+
41
+ # 1. Clean-machine preconditions: git must be absent; record winget state.
42
+ # (Windows Server AMIs never ship winget — the winget-specific install leg
43
+ # needs consumer Windows and stays covered by launch.test.js + the MSIX
44
+ # contract job; record the state so the gap is visible, never silent.)
45
+ $results.steps.preconditions = [ordered]@{
46
+ gitPresent = [bool](Get-Command git -ErrorAction SilentlyContinue)
47
+ wingetPresent = [bool](Get-Command winget -ErrorAction SilentlyContinue)
48
+ osCaption = (Get-CimInstance Win32_OperatingSystem).Caption
49
+ }
50
+
51
+ # 2. Install Node headless from the official MSI.
52
+ Invoke-WebRequest -Uri 'https://nodejs.org/dist/__NODE_VERSION__/node-__NODE_VERSION__-x64.msi' -OutFile "$root\node.msi" -UseBasicParsing
53
+ $msi = Start-Process msiexec.exe -ArgumentList '/i', "$root\node.msi", '/qn', '/norestart' -Wait -PassThru
54
+ $results.steps.nodeMsiExit = $msi.ExitCode
55
+ $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
56
+ $nodeDir = 'C:\Program Files\nodejs'
57
+ $results.steps.nodeVersion = & "$nodeDir\node.exe" --version 2>&1 | Out-String
58
+ $results.steps.npmVersion = & "$nodeDir\npm.cmd" --version 2>&1 | Out-String
59
+
60
+ # 3. Fetch artifacts from S3 via the instance role.
61
+ Read-S3Object -BucketName $bucket -Key '__CLI_TARBALL__' -File "$root\impel-cli.tgz" -Region $region | Out-Null
62
+ Read-S3Object -BucketName $bucket -Key 'impel-cli-source.tgz' -File "$root\source.tgz" -Region $region | Out-Null
63
+
64
+ # 4. npm global tarball install on a git-less machine (install-channel class).
65
+ $results.steps.npmInstall = Run-Step 'npm-install' "$nodeDir\npm.cmd" @('install', '--global', "$root\impel-cli.tgz") 300
66
+ $npmPrefix = (& "$nodeDir\npm.cmd" prefix -g 2>$null | Out-String).Trim()
67
+ $results.steps.npmPrefix = $npmPrefix
68
+ $impelCmd = Join-Path $npmPrefix 'impel.cmd'
69
+ $results.steps.impelCmdExists = Test-Path $impelCmd
70
+ $pkgDir = Join-Path $npmPrefix 'node_modules\impel-cli'
71
+ $item = Get-Item $pkgDir -ErrorAction SilentlyContinue
72
+ $results.steps.installIsRealDir = ($null -ne $item) -and (-not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint))
73
+
74
+ # 5. Post-conditions, not exit codes: version, doctor, setup must be
75
+ # actionable, never hang, never dump a stack trace at the user.
76
+ if ($results.steps.impelCmdExists) {
77
+ $results.steps.version = Run-Step 'impel-version' $impelCmd @('--version') 60
78
+ $results.steps.doctor = Run-Step 'impel-doctor' $impelCmd @('doctor') 180
79
+ $results.steps.setupNoAuth = Run-Step 'impel-setup' $impelCmd @('setup') 180 -stdin ''
80
+ }
81
+ $results.steps.nodeProcsAfter = @(Get-Process -Name node -ErrorAction SilentlyContinue).Count
82
+ $results.steps.cmdProcsAfter = @(Get-Process -Name cmd -ErrorAction SilentlyContinue).Count
83
+
84
+ # 6. The full ungated test suite on a machine with no git and no dev tooling —
85
+ # the leg that catches environment-sensitive tests CI can never see.
86
+ New-Item -ItemType Directory -Force -Path "$root\src" | Out-Null
87
+ tar.exe -xzf "$root\source.tgz" -C "$root\src"
88
+ Push-Location "$root\src"
89
+ $env:HOME = "$root\home"; New-Item -ItemType Directory -Force -Path $env:HOME | Out-Null
90
+ $results.steps.testSuite = Run-Step 'node-test' "$nodeDir\node.exe" @('--test') 1500
91
+ Pop-Location
92
+
93
+ $results.finishedAt = (Get-Date).ToUniversalTime().ToString('o')
94
+ $results | ConvertTo-Json -Depth 6 | Set-Content -Path "$root\results.json" -Encoding UTF8
95
+ Stop-Transcript
96
+ Write-S3Object -BucketName $bucket -Key 'results/results.json' -File "$root\results.json" -Region $region
97
+ Write-S3Object -BucketName $bucket -Key 'results/transcript.txt' -File "$root\transcript.txt" -Region $region
98
+ foreach ($f in Get-ChildItem "$root\*.out.txt", "$root\*.err.txt" -ErrorAction SilentlyContinue) {
99
+ Write-S3Object -BucketName $bucket -Key "results/$($f.Name)" -File $f.FullName -Region $region
100
+ }
101
+ Stop-Computer -Force
102
+ </powershell>