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 +25 -12
- package/RELEASE_NOTES.md +21 -0
- package/bin/impel.js +10 -1
- package/docs/native-agent-host-capability-matrix.md +16 -4
- package/package.json +1 -1
- package/scripts/capture-release-fixtures.mjs +56 -0
- package/scripts/clean-machine/README.md +51 -0
- package/scripts/clean-machine/run-clean-windows.sh +115 -0
- package/scripts/clean-machine/userdata.ps1.template +102 -0
- package/scripts/regen-managed-artifacts.mjs +475 -0
- package/scripts/verify-vendor-pins.mjs +207 -0
- package/src/agents.js +256 -32
- package/src/apps.js +233 -2
- package/src/cli.js +1 -0
- package/src/commands/apps.js +25 -2
- package/src/commands/launch.js +177 -4
- package/src/commands/mcp.js +1 -1
- package/src/commands/sessions.js +44 -11
- package/src/commands/token.js +23 -3
- package/src/macSetup.js +9 -8
- package/src/nativeAgentTransport.js +13 -4
- package/src/selfInvocation.js +12 -2
- package/src/vendorCliBinaries.js +36 -7
- package/src/verbatimRelay.js +9 -1
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
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
|
|
43
|
-
agent, not this wrapper model, still owns the
|
|
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
|
@@ -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>
|