@atolis-hq/wake 0.1.22 → 0.2.0

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
@@ -1,4 +1,48 @@
1
- # Wake
1
+ <div align="center">
2
+
3
+
4
+ <img src="https://raw.githubusercontent.com/atolis-hq/wake/refs/heads/main/assets/wake-logo.svg" alt="logo" width="200" height="auto" />
5
+ <h1>Wake</h1>
6
+
7
+ <p>
8
+ Autonomous software engineering control plane
9
+ </p>
10
+
11
+
12
+ <!-- Badges -->
13
+ <p>
14
+ <a href="https://github.com/atolis-hq/wake/commits/main">
15
+ <img src="https://img.shields.io/github/last-commit/atolis-hq/wake" alt="last update" />
16
+ </a>
17
+ <a href="https://github.com/atolis-hq/wake/actions/workflows/ci-cd.yml">
18
+ <img src="https://github.com/atolis-hq/wake/actions/workflows/ci-cd.yml/badge.svg" alt="CI/CD status" />
19
+ </a>
20
+ <!-- <a href="https://github.com/atolis-hq/wake/network/members">
21
+ <img src="https://img.shields.io/github/forks/atolis-hq/wake" alt="forks" />
22
+ </a>
23
+ <a href="https://github.com/atolis-hq/wake/stargazers">
24
+ <img src="https://img.shields.io/github/stars/atolis-hq/wake" alt="stars" />
25
+ </a> -->
26
+ <a href="https://github.com/atolis-hq/wake/issues/">
27
+ <img src="https://img.shields.io/github/issues/atolis-hq/wake" alt="open issues" />
28
+ </a>
29
+ <a href="https://github.com/atolis-hq/wake/blob/main/LICENSE">
30
+ <img src="https://img.shields.io/github/license/atolis-hq/wake.svg" alt="license" />
31
+ </a>
32
+ <a href="https://github.com/atolis-hq/wake/tags">
33
+ <img src="https://img.shields.io/github/v/tag/atolis-hq/wake" alt="latest tag" />
34
+ </a>
35
+ <a href="https://www.npmjs.com/package/@atolis-hq/wake">
36
+ <img src="https://img.shields.io/npm/v/%40atolis-hq%2Fwake" alt="npm version" />
37
+ </a>
38
+ <a href="docker/Dockerfile">
39
+ <img src="https://img.shields.io/badge/sandbox-docker-2496ED?logo=docker&logoColor=white" alt="runs in docker" />
40
+ </a>
41
+ </p>
42
+ </div>
43
+
44
+ <br />
45
+
2
46
 
3
47
  Wake is a control plane for autonomous software engineering. It watches the
4
48
  channels your team already uses, coordinates agent activity and involves humans
@@ -10,7 +54,19 @@ asks for input when human judgment matters, proposes a plan, launches local
10
54
  coding-agent CLIs to implement changes, opens pull requests, and carries the
11
55
  conversation forward wherever the work is already happening.
12
56
 
13
- Wake owns coordination. Coding agents execute the work.
57
+
58
+ ## Table of Contents
59
+
60
+ - [The Problem](#the-problem)
61
+ - [Vision](#vision)
62
+ - [Feature Overview](#feature-overview)
63
+ - [Where the Work Happens](#where-the-work-happens)
64
+ - [Supported Agent CLIs](#supported-agent-clis)
65
+ - [Getting Started](#getting-started)
66
+ - [Documentation](#documentation)
67
+ - [Issues & Feature Requests](#issues--feature-requests)
68
+ - [License](#license)
69
+
14
70
 
15
71
  ## The Problem
16
72
 
@@ -58,8 +114,10 @@ For more detail, see [docs/vision.md](docs/vision.md) and
58
114
  - **Event-sourced and restart-safe.** The durable record is an append-only event
59
115
  log; projections can be rebuilt, and the loop can crash and resume without
60
116
  losing its place.
61
- - **Local and inspectable.** Config, events, state, runs, workspaces, and prompts
62
- live in a plain-file Wake home directory.
117
+ - **Local and inspectable.** Everything lives in a plain-file Wake home
118
+ directory: `config.json`, `prompts/`, and `workspaces/` at the top level for
119
+ what you edit or browse day-to-day, with durable/internal state (events,
120
+ projections, runs, logs, sandbox auth) nested under a hidden `.wake/`.
63
121
  - **Sandbox-oriented execution.** Wake can run from a persistent Docker sandbox
64
122
  with durable auth state and mounted Wake home data.
65
123
  - **Runner agnostic.** Claude Code, Codex, Cursor, and fake runners sit behind
@@ -75,71 +133,67 @@ For more detail, see [docs/vision.md](docs/vision.md) and
75
133
  Current runner capability differences are documented in
76
134
  [docs/runner-comparison.md](docs/runner-comparison.md).
77
135
 
78
- ## Getting Started
136
+ ## Where the Work Happens
79
137
 
80
- Wake is distributed as the `@atolis-hq/wake` npm package. You can run the CLI
81
- with `npx` or install it globally:
138
+ Wake has no chat UI you need to check for status. Your ticketing system is the
139
+ interface: Wake posts progress updates, asks clarifying questions, and reports
140
+ results as comments on the ticket, and reflects stage and status as labels on
141
+ it. When it's ready, it opens a pull request against your repo the normal way.
142
+ Reviewing, approving, and merging happen exactly where they already do today —
143
+ nothing new to learn, no separate dashboard to babysit.
82
144
 
83
- ```sh
84
- npx @atolis-hq/wake init ./wake-home
85
- ```
145
+ A local control-plane UI exists for operators who want to watch runs, inspect
146
+ events, or resume a session directly, but it's a window into the same state —
147
+ not a required part of the workflow.
86
148
 
87
- ```sh
88
- npm install -g @atolis-hq/wake
89
- wake init ./wake-home
90
- ```
149
+ ## Supported Agent CLIs
150
+
151
+ Wake wraps existing coding-agent CLIs rather than replacing them. Runner
152
+ adapters currently exist for:
91
153
 
92
- `wake init` creates a Wake home directory with `config.json`, prompt templates,
93
- Docker sandbox assets, runtime directories, and shell launchers:
154
+ - **[Claude Code](https://claude.com/claude-code)**
155
+ - **[Codex](https://openai.com/codex/)**
156
+ - **[Cursor](https://cursor.com/cli)**
94
157
 
95
- - `wake.sh` for bash, Git Bash, WSL, and similar shells.
96
- - `wake.ps1` for PowerShell.
158
+ Each runner sits behind the same `AgentRunner` contract, so Wake's routing,
159
+ lifecycle, and sandbox behavior stay the same regardless of which CLI executes
160
+ a given step. A fake runner adapter also exists for zero-token testing of the
161
+ control plane itself. See [docs/runner-comparison.md](docs/runner-comparison.md)
162
+ for capability differences between runners.
97
163
 
98
- Use the generated launcher from the Wake home for day-to-day operation. The
99
- launcher runs host setup commands locally and forwards runtime commands into the
100
- sandbox with the correct Wake home mounted at `/wake`.
164
+ ## Getting Started
101
165
 
102
166
  ```sh
167
+ npm install -g @atolis-hq/wake
168
+ cd ~/
169
+ wake init ./wake-home
103
170
  cd ./wake-home
104
- ./wake.sh sandbox build
105
- ./wake.sh sandbox up
106
- ./wake.sh sandbox setup
107
- ./wake.sh tick
108
- ./wake.sh start
171
+ wake sandbox build
172
+ wake sandbox up
173
+ wake sandbox setup
174
+ wake start
109
175
  ```
110
176
 
111
- The default sandbox image includes Node, Git, GitHub CLI, Claude Code, Codex,
112
- Cursor, and the Wake runtime. Treat the generated `docker/Dockerfile` as a
113
- starting point for your own environment: add the tools your repositories need,
114
- then rebuild with `./wake.sh sandbox build`. Wake writes the package location to
115
- `config.json` as `dev.repoRoot` so sandbox rebuilds use the same bundled assets;
116
- editing your generated Dockerfile or prompts is expected and future package
117
- upgrades should not overwrite that Wake home.
118
-
119
- Common commands:
120
-
121
- ```sh
122
- ./wake.sh ui
123
- ./wake.sh tick
124
- ./wake.sh start
125
- ./wake.sh stop
126
- ./wake.sh sandbox resume <session-id> --cwd "/wake/workspaces/<workId>"
127
- ```
177
+ Full setup instructions are in
178
+ [docs/getting-started.md](docs/getting-started.md). Run `wake --help` at
179
+ any time for the full command list.
128
180
 
129
- For a source checkout development workflow, use:
181
+ ## Documentation
130
182
 
131
- - [docs/development.md](docs/development.md)
183
+ - [docs/getting-started.md](docs/getting-started.md) — packaged-install setup, sandbox lifecycle, `wake doctor`.
184
+ - [docs/vision.md](docs/vision.md) — the rationale and long-term direction for Wake.
185
+ - [docs/architecture.md](docs/architecture.md) — module boundaries and the event-sourced core.
186
+ - [docs/workflows.md](docs/workflows.md) — how stages, prompts, and runner routes are configured.
187
+ - [docs/prompts.md](docs/prompts.md) — how prompt templates map to workflow stages.
188
+ - [docs/configuration.md](docs/configuration.md) — `config.json` options and the operator correlation escape hatch.
189
+ - [docs/development.md](docs/development.md) — source-checkout dev setup (`wake-dev`), npm scripts, formatting, self-update, GitHub polling.
190
+ - [docs/runner-comparison.md](docs/runner-comparison.md) — capability differences between supported runners.
132
191
 
133
- Recommended local practices:
192
+ ## Issues & Feature Requests
134
193
 
135
- - Use a separate git identity for Wake-managed agent work so automated commits
136
- and human commits are easy to distinguish.
137
- - Prefer the prebuilt sandbox flow when running real agent work locally.
138
- - Treat the default Dockerfile as a starting point. It includes common tooling
139
- such as Node, but it is expected to be edited for the repositories and agents
140
- you want Wake to operate on.
194
+ Found a bug or have an idea for Wake? [Open an issue](https://github.com/atolis-hq/wake/issues/new)
195
+ bug reports and feature requests are both welcome.
141
196
 
142
- ## Development
197
+ ## License
143
198
 
144
- Local setup, commands, sandbox operation, auth setup, UI notes, and GitHub
145
- polling details are documented in [docs/development.md](docs/development.md).
199
+ Wake is licensed under the [Apache License 2.0](LICENSE).
@@ -1,3 +1,5 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { scrubSecrets } from '../../cli/sandbox-exec-logging.js';
1
3
  function buildStopArgs(containerName, timeoutSeconds) {
2
4
  return [
3
5
  'stop',
@@ -50,7 +52,19 @@ function buildRunArgs(input) {
50
52
  export function createDockerCli(deps) {
51
53
  return {
52
54
  async build(input) {
53
- await deps.run(['build', '-t', input.image, '-f', input.dockerfile, input.contextDir]);
55
+ const buildArgFlags = Object.entries(input.buildArgs ?? {}).flatMap(([key, value]) => [
56
+ '--build-arg',
57
+ `${key}=${value}`,
58
+ ]);
59
+ await deps.run([
60
+ 'build',
61
+ '-t',
62
+ input.image,
63
+ '-f',
64
+ input.dockerfile,
65
+ ...buildArgFlags,
66
+ input.contextDir,
67
+ ]);
54
68
  },
55
69
  async up(input) {
56
70
  const imageExists = await deps.inspectImage(input.image);
@@ -90,6 +104,36 @@ export function createDockerCli(deps) {
90
104
  ? ['exec', interactive ? '-it' : '-i', containerName, ...command]
91
105
  : ['exec', '-it', containerName, 'bash']);
92
106
  },
107
+ /**
108
+ * Runs `docker exec -i <containerName> <command>` with piped (not
109
+ * inherited) stdio, line-buffers stdout/stderr, scrubs secrets from each
110
+ * line, and forwards it to the caller's handlers in real time. Used by
111
+ * `sandbox exec` so live output is observed and redacted on the host
112
+ * instead of being wrapped by a mounted in-container script.
113
+ */
114
+ async execCaptured(containerName, command, handlers) {
115
+ if (deps.spawnExec === undefined) {
116
+ throw new Error('docker cli adapter was not configured with spawnExec');
117
+ }
118
+ const args = ['exec', '-i', containerName, ...command];
119
+ const child = deps.spawnExec(args);
120
+ const stdoutReader = createInterface({ input: child.stdout });
121
+ stdoutReader.on('line', (line) => handlers.onStdout(scrubSecrets(line)));
122
+ const stderrReader = createInterface({ input: child.stderr });
123
+ stderrReader.on('line', (line) => handlers.onStderr(scrubSecrets(line)));
124
+ await new Promise((resolveExec, reject) => {
125
+ child.on('error', reject);
126
+ child.on('close', (exitCode) => {
127
+ stdoutReader.close();
128
+ stderrReader.close();
129
+ if (exitCode === 0) {
130
+ resolveExec();
131
+ return;
132
+ }
133
+ reject(new Error(`docker ${args.join(' ')} failed with exit code ${exitCode ?? 1}`));
134
+ });
135
+ });
136
+ },
93
137
  async logs(containerName, tailLines) {
94
138
  await deps.run(['logs', '--tail', String(tailLines), containerName]);
95
139
  },
@@ -58,7 +58,7 @@ function shouldArchiveIssueState(item, options) {
58
58
  return Number.isFinite(ageMs) && ageMs > options.archiveFreshnessDays * 24 * 60 * 60 * 1000;
59
59
  }
60
60
  export async function listRunRecords(wakeRoot) {
61
- const runsRoot = join(wakeRoot, 'runs');
61
+ const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
62
62
  const recordsById = new Map();
63
63
  try {
64
64
  const files = (await readdir(runsRoot)).filter((file) => file.endsWith('.json')).sort();
@@ -93,7 +93,7 @@ export async function listRunRecords(wakeRoot) {
93
93
  return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
94
94
  }
95
95
  async function listRunRecordsForDate(wakeRoot, date) {
96
- const runsRoot = join(wakeRoot, 'runs');
96
+ const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
97
97
  const recordsById = new Map();
98
98
  const bucketFiles = (await readdir(join(runsRoot, 'by-date', date)).catch(() => []))
99
99
  .filter((file) => file.endsWith('.json'))
@@ -118,7 +118,7 @@ async function listRunRecordsForDate(wakeRoot, date) {
118
118
  return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
119
119
  }
120
120
  async function listRecentRunRecords(wakeRoot, limit) {
121
- const runsRoot = join(wakeRoot, 'runs');
121
+ const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
122
122
  const recordsById = new Map();
123
123
  const dateDirs = (await readdir(join(runsRoot, 'by-date')).catch(() => [])).sort().reverse();
124
124
  for (const dateDir of dateDirs) {
@@ -227,7 +227,7 @@ export function createStateStore({ wakeRoot }) {
227
227
  }
228
228
  },
229
229
  async listIssueStates(options = {}) {
230
- const stateRoot = join(wakeRoot, 'state');
230
+ const stateRoot = join(paths.dataRoot, 'state');
231
231
  try {
232
232
  const items = [];
233
233
  const includeArchived = options.includeArchived ?? false;
@@ -284,7 +284,7 @@ export function createStateStore({ wakeRoot }) {
284
284
  }
285
285
  },
286
286
  async listEventEnvelopes() {
287
- const eventsRoot = join(wakeRoot, 'events');
287
+ const eventsRoot = join(paths.dataRoot, 'events');
288
288
  try {
289
289
  const files = (await readdir(eventsRoot)).sort();
290
290
  const envelopes = [];
@@ -299,7 +299,7 @@ export function createStateStore({ wakeRoot }) {
299
299
  },
300
300
  async listRecentEventEnvelopes(filter = {}) {
301
301
  const limit = filter.limit ?? 200;
302
- const eventsRoot = join(wakeRoot, 'events');
302
+ const eventsRoot = join(paths.dataRoot, 'events');
303
303
  const files = (await readdir(eventsRoot).catch(() => []))
304
304
  .filter((file) => file.endsWith('.jsonl'))
305
305
  .sort()
@@ -1,10 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
2
  import { defaultAgentIdentity } from '../../domain/schema.js';
4
3
  import { buildResourceUri } from '../../domain/resource-uri.js';
5
4
  import { wakeStageLabelPrefix } from '../../domain/stages.js';
6
5
  import { wakeWorkflowLabelPrefix } from '../../domain/workflows.js';
7
6
  import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
7
+ import { createWakePaths } from '../../lib/paths.js';
8
8
  import { wakeVersion } from '../../version.js';
9
9
  import { buildResumeCommandForCli } from '../runner/runner-cli-adapter.js';
10
10
  const wakeStatusLabelPrefix = 'wake:status.';
@@ -173,7 +173,7 @@ function formatControlPlaneLink(url) {
173
173
  }
174
174
  export async function readControlPlaneUiUrl(wakeRoot) {
175
175
  try {
176
- const raw = await readFile(resolve(wakeRoot, 'control-plane-ui-url'), 'utf8');
176
+ const raw = await readFile(createWakePaths(wakeRoot).controlPlaneUiUrlFile, 'utf8');
177
177
  return formatControlPlaneLink(raw.trim()) ?? undefined;
178
178
  }
179
179
  catch {
@@ -0,0 +1,48 @@
1
+ export async function runDoctorCommand(config, deps) {
2
+ const failures = [...(await deps.collectPreflightFailures(config))];
3
+ const notices = [];
4
+ if (config.sources.github.enabled) {
5
+ try {
6
+ await deps.resolveGitHubToken();
7
+ }
8
+ catch (error) {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ failures.push(`GitHub token could not be resolved: ${message}`);
11
+ }
12
+ }
13
+ if (await deps.hasDockerfile(deps.wakeRoot)) {
14
+ const reachable = await deps.dockerReachable();
15
+ if (!reachable) {
16
+ failures.push('Docker daemon is not reachable');
17
+ }
18
+ else {
19
+ const imageExists = await deps.inspectImage(deps.image);
20
+ if (!imageExists) {
21
+ failures.push(`sandbox image "${deps.image}" not found — run \`wake sandbox build\``);
22
+ }
23
+ }
24
+ }
25
+ try {
26
+ if (await deps.containerRunning()) {
27
+ const sandboxVersion = await deps.execVersionInContainer();
28
+ if (sandboxVersion !== '' && sandboxVersion !== deps.installedVersion) {
29
+ notices.push(`sandbox is running version ${sandboxVersion}, installed CLI is ${deps.installedVersion} — run \`wake sandbox build && wake sandbox update\` to sync`);
30
+ }
31
+ }
32
+ }
33
+ catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ notices.push(`could not check sandbox version: ${message}`);
36
+ }
37
+ try {
38
+ const driftedFiles = await deps.diffPromptsAndDockerfile();
39
+ for (const file of driftedFiles) {
40
+ notices.push(`${file} differs from the currently-shipped default (not auto-overwritten)`);
41
+ }
42
+ }
43
+ catch (error) {
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ notices.push(`could not check prompt/Dockerfile drift: ${message}`);
46
+ }
47
+ return { failures, notices };
48
+ }
@@ -1,11 +1,18 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { assertEmptyDirectory, scaffoldWakeHome } from './scaffold-assets.js';
3
3
  export async function runInitCommand(input) {
4
- const wakeRoot = resolve(input.cwd, input.args[0] ?? '.');
4
+ const positionalArgs = input.args.filter((arg) => arg !== '--dev' && arg !== '--packaged');
5
+ const wakeRoot = resolve(input.cwd, positionalArgs[0] ?? '.');
6
+ const devModeOverride = input.args.includes('--dev')
7
+ ? 'source'
8
+ : input.args.includes('--packaged')
9
+ ? 'packaged'
10
+ : undefined;
5
11
  await assertEmptyDirectory(wakeRoot);
6
12
  await scaffoldWakeHome({
7
13
  wakeRoot,
8
14
  repoRoot: input.repoRoot,
15
+ ...(devModeOverride === undefined ? {} : { devModeOverride }),
9
16
  });
10
17
  return { wakeRoot };
11
18
  }
@@ -1,10 +1,25 @@
1
- import { mkdir } from 'node:fs/promises';
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { posix, resolve } from 'node:path';
3
3
  import { createRunnerCliAdapter } from '../adapters/runner/runner-cli-adapter.js';
4
4
  import { runSandboxResumeCommand } from './sandbox-resume.js';
5
5
  import { runSelfUpdateCommand, runSelfUpdateLoop } from './self-update-command.js';
6
6
  import { runStopCommand } from './stop-command.js';
7
- import { buildSandboxLoggedCommand } from './sandbox-logging.js';
7
+ import { wakeVersion } from '../version.js';
8
+ async function ensureDockerfile(input) {
9
+ const targetPath = resolve(input.wakeRoot, 'docker', 'Dockerfile');
10
+ try {
11
+ await access(targetPath);
12
+ return; // already present — user-owned, never overwritten
13
+ }
14
+ catch {
15
+ // fall through to write it
16
+ }
17
+ const mode = input.devMode ?? 'packaged';
18
+ const templatePath = resolve(input.packagedTemplatesRoot, mode === 'source' ? 'Dockerfile' : 'Dockerfile.packaged');
19
+ const content = await readFile(templatePath, 'utf8');
20
+ await mkdir(resolve(input.wakeRoot, 'docker'), { recursive: true });
21
+ await writeFile(targetPath, content, 'utf8');
22
+ }
8
23
  async function ensureContainerHomeMountParents(input) {
9
24
  for (const mount of input.extraMounts) {
10
25
  const relativeTarget = posix.relative(input.containerHomeMountPath, mount.target);
@@ -47,10 +62,17 @@ export async function runSandboxCommand(input) {
47
62
  if (repoRoot === undefined || repoRoot.length === 0) {
48
63
  throw new Error('Sandbox build requires config.dev.repoRoot');
49
64
  }
65
+ const effectiveDevMode = input.config.dev?.mode ?? 'packaged';
66
+ await ensureDockerfile({
67
+ wakeRoot: input.wakeRoot,
68
+ devMode: effectiveDevMode,
69
+ packagedTemplatesRoot: input.packagedTemplatesRoot,
70
+ });
50
71
  await input.docker.build({
51
72
  image: input.config.sandbox.image,
52
73
  dockerfile: resolve(input.wakeRoot, 'docker', 'Dockerfile'),
53
74
  contextDir: repoRoot,
75
+ ...(effectiveDevMode === 'packaged' ? { buildArgs: { WAKE_VERSION: wakeVersion } } : {}),
54
76
  });
55
77
  return;
56
78
  }
@@ -113,7 +135,8 @@ export async function runSandboxCommand(input) {
113
135
  throw new Error('Sandbox self-update requires config.dev.repoRoot');
114
136
  }
115
137
  if (input.selfUpdate === undefined) {
116
- throw new Error('Sandbox self-update requires git/issueReporter/ledger dependencies');
138
+ throw new Error('Sandbox self-update requires dev.mode: "source". For a packaged install, update instead with:\n' +
139
+ ' npm install -g @atolis-hq/wake@latest && wake sandbox build && wake sandbox update');
117
140
  }
118
141
  const selfUpdateArgs = input.args.slice(1);
119
142
  const runSelfUpdate = selfUpdateArgs.includes('--loop')
@@ -146,7 +169,10 @@ export async function runSandboxCommand(input) {
146
169
  return;
147
170
  }
148
171
  if (subcommand === 'setup') {
149
- await input.docker.exec(input.config.sandbox.containerName, ['bash', '/wake/docker/setup.sh'], {
172
+ const setupCommand = input.config.dev?.mode === 'source'
173
+ ? ['node', '/app/dist/src/main.js', 'sandbox-setup']
174
+ : ['wake', 'sandbox-setup'];
175
+ await input.docker.exec(input.config.sandbox.containerName, setupCommand, {
150
176
  interactive: true,
151
177
  });
152
178
  return;
@@ -154,15 +180,18 @@ export async function runSandboxCommand(input) {
154
180
  if (subcommand === 'exec') {
155
181
  const commandArgs = input.args.slice(1);
156
182
  const wrappedCommand = commandArgs[0] === '--' ? commandArgs.slice(1) : commandArgs;
157
- await input.docker.exec(input.config.sandbox.containerName, wrappedCommand.length === 0
158
- ? []
159
- : buildSandboxLoggedCommand({
160
- label: 'sandbox.exec',
161
- config: input.config,
162
- wakeRoot: input.wakeRoot,
163
- containerHomeRoot: input.containerHomeRoot,
164
- command: wrappedCommand,
165
- }));
183
+ if (wrappedCommand.length === 0) {
184
+ // No command given: drop into an interactive shell with a real TTY,
185
+ // same as `docker exec -it ... bash`. That's a genuine interactive
186
+ // use case, so it stays on the inherited-stdio path rather than the
187
+ // piped/scrubbed one below (which would break TTY behavior).
188
+ await input.docker.exec(input.config.sandbox.containerName, []);
189
+ return;
190
+ }
191
+ await input.docker.execCaptured(input.config.sandbox.containerName, wrappedCommand, {
192
+ onStdout: (line) => input.logger.info(line),
193
+ onStderr: (line) => (input.logger.error ?? input.logger.info)(line),
194
+ });
166
195
  return;
167
196
  }
168
197
  if (subcommand === 'logs') {
@@ -186,6 +215,7 @@ export async function runSandboxCommand(input) {
186
215
  wakeRoot: input.wakeRoot,
187
216
  containerHomeRoot: input.containerHomeRoot,
188
217
  buildResumeCommand: runnerAdapter.buildResumeCommand,
218
+ logger: input.logger,
189
219
  });
190
220
  return;
191
221
  }
@@ -0,0 +1,88 @@
1
+ import { createWakePaths } from '../lib/paths.js';
2
+ // The entrypoint always runs against the fixed in-container mount point
3
+ // (/wake), so a literal wakeRoot here is safe.
4
+ const CONTROL_PLANE_UI_URL_FILE = createWakePaths('/wake').controlPlaneUiUrlFile;
5
+ const DEFAULT_UI_PORT = '4317';
6
+ const DEFAULT_START_RESTART_DELAY_SECONDS = 10;
7
+ /**
8
+ * Resolves how to invoke the wake CLI from inside the container.
9
+ *
10
+ * Source-mode images (docker/Dockerfile) bake `WAKE_MAIN_JS` at build time,
11
+ * pointing at the compiled entrypoint under /app. Packaged-mode images
12
+ * (docker/Dockerfile.packaged) have no /app directory at all — the CLI is
13
+ * installed globally via npm and exposed as a `wake` binary on PATH, so the
14
+ * absence of WAKE_MAIN_JS is the signal to invoke it bare.
15
+ */
16
+ function resolveWakeInvocation(env) {
17
+ const mainJs = env.WAKE_MAIN_JS;
18
+ return mainJs !== undefined
19
+ ? { command: 'node', argsPrefix: [mainJs] }
20
+ : { command: 'wake', argsPrefix: [] };
21
+ }
22
+ async function discoverAndWriteNgrokUrl(deps) {
23
+ await deps.removeFile(CONTROL_PLANE_UI_URL_FILE);
24
+ const publicUrl = await deps.discoverNgrokUrl();
25
+ if (publicUrl !== undefined) {
26
+ await deps.writeFile(CONTROL_PLANE_UI_URL_FILE, `${publicUrl}\n`);
27
+ deps.log(`wake ui: ngrok tunnel available at ${publicUrl}`);
28
+ return;
29
+ }
30
+ deps.log('wake ui: ngrok tunnel started but public URL was not discovered; see /wake/.wake/logs/ngrok.log');
31
+ }
32
+ async function superviseWakeStart(deps, restartDelaySeconds) {
33
+ for (;;) {
34
+ deps.log('wake start: starting resident loop');
35
+ const { command, argsPrefix } = resolveWakeInvocation(deps.env);
36
+ // --no-sandbox: this already IS the sandbox — docker/Dockerfile is
37
+ // bind-mounted at /wake, so without this the process's own
38
+ // dispatchMainCommand would see it and try to auto-delegate into
39
+ // `docker exec`, which doesn't exist inside the container itself.
40
+ const { pid } = deps.spawnDetached(command, [...argsPrefix, 'start', '--wake-root', '/wake', '--no-sandbox'], { logFile: '/wake/.wake/logs/start.log' });
41
+ await deps.writeFile('/wake/.wake/logs/start.pid', String(pid));
42
+ const exitCode = await deps.waitForExit(pid);
43
+ deps.log(`wake start: resident loop exited with status ${exitCode}; restarting in ${restartDelaySeconds}s`);
44
+ await deps.sleep(restartDelaySeconds * 1000);
45
+ }
46
+ }
47
+ export async function runSandboxEntrypointCommand(deps) {
48
+ const { env } = deps;
49
+ await deps.ensureDir('/wake/.wake/logs');
50
+ if (env.WAKE_UI_ENABLED === 'true') {
51
+ const port = env.WAKE_UI_PORT ?? DEFAULT_UI_PORT;
52
+ deps.log(`wake ui: starting on 0.0.0.0:${port}`);
53
+ const { command, argsPrefix } = resolveWakeInvocation(env);
54
+ // --no-sandbox: same reason as the `wake start` spawn below — without
55
+ // it, this process would try to `docker exec` itself from inside the
56
+ // container it's already running in.
57
+ const uiArgs = [
58
+ ...argsPrefix,
59
+ 'ui',
60
+ '--wake-root',
61
+ '/wake',
62
+ '--host',
63
+ '0.0.0.0',
64
+ '--port',
65
+ port,
66
+ '--no-sandbox',
67
+ ];
68
+ if (env.WAKE_UI_TOKEN) {
69
+ uiArgs.push('--token', env.WAKE_UI_TOKEN);
70
+ }
71
+ deps.spawnDetached(command, uiArgs, { logFile: '/wake/.wake/logs/ui.log' });
72
+ if (env.WAKE_UI_TUNNEL_ENABLED === 'true') {
73
+ if (env.NGROK_AUTHTOKEN) {
74
+ const { pid } = deps.spawnDetached('ngrok', ['config', 'add-authtoken', env.NGROK_AUTHTOKEN], { logFile: '/wake/.wake/logs/ngrok.log' });
75
+ await deps.waitForExit(pid);
76
+ }
77
+ deps.log(`wake ui: starting ngrok tunnel for 127.0.0.1:${port}`);
78
+ deps.spawnDetached('ngrok', ['http', `127.0.0.1:${port}`, '--log=stdout'], {
79
+ logFile: '/wake/.wake/logs/ngrok.log',
80
+ });
81
+ void discoverAndWriteNgrokUrl(deps);
82
+ }
83
+ }
84
+ if (env.WAKE_START_ENABLED === 'true') {
85
+ const restartDelaySeconds = Number(env.WAKE_START_RESTART_DELAY_SECONDS ?? DEFAULT_START_RESTART_DELAY_SECONDS);
86
+ void superviseWakeStart(deps, restartDelaySeconds);
87
+ }
88
+ }
@@ -0,0 +1,7 @@
1
+ const ENV_SECRET_PATTERN = /([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY)[A-Za-z0-9_]*=)[^\s]+/gi;
2
+ const GITHUB_TOKEN_PATTERN = /(?:gho|ghp|github_pat)_[A-Za-z0-9_]+/g;
3
+ export function scrubSecrets(line) {
4
+ return line
5
+ .replace(ENV_SECRET_PATTERN, '$1[REDACTED]')
6
+ .replace(GITHUB_TOKEN_PATTERN, '[REDACTED]');
7
+ }
@@ -2,7 +2,13 @@ import { stdin as input, stdout as output } from 'node:process';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { join } from 'node:path';
4
4
  import { listRunRecords } from '../adapters/fs/state-store.js';
5
- import { buildSandboxLoggedCommand } from './sandbox-logging.js';
5
+ function shellQuote(value) {
6
+ return `'${value.replaceAll("'", `'\\''`)}'`;
7
+ }
8
+ function buildCwdWrappedShellCommand(cwd, command) {
9
+ const shellCommand = `cd ${shellQuote(cwd)} && ${command.map(shellQuote).join(' ')}`;
10
+ return ['sh', '-c', shellCommand];
11
+ }
6
12
  function readFlag(name, args) {
7
13
  const index = args.indexOf(name);
8
14
  if (index === -1) {
@@ -66,12 +72,8 @@ export async function runSandboxResumeCommand(input) {
66
72
  }
67
73
  throw new Error('No resumable sandbox session selected.');
68
74
  }
69
- await input.docker.exec(input.config.sandbox.containerName, buildSandboxLoggedCommand({
70
- label: 'sandbox.resume',
71
- config: input.config,
72
- wakeRoot: input.wakeRoot,
73
- containerHomeRoot: input.containerHomeRoot,
74
- cwd: target.workspacePath,
75
- command: input.buildResumeCommand({ sessionId: target.sessionId }),
76
- }));
75
+ await input.docker.execCaptured(input.config.sandbox.containerName, buildCwdWrappedShellCommand(target.workspacePath, input.buildResumeCommand({ sessionId: target.sessionId })), {
76
+ onStdout: (line) => input.logger.info(line),
77
+ onStderr: (line) => (input.logger.error ?? input.logger.info)(line),
78
+ });
77
79
  }