@atolis-hq/wake 0.1.23 → 0.2.1

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
@@ -11,33 +11,15 @@
11
11
 
12
12
  <!-- Badges -->
13
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>
14
+ <a href="https://github.com/atolis-hq/wake/commits/main"><img src="https://img.shields.io/github/last-commit/atolis-hq/wake" alt="last update" /></a>
15
+ <a href="https://github.com/atolis-hq/wake/actions/workflows/ci-cd.yml"><img src="https://github.com/atolis-hq/wake/actions/workflows/ci-cd.yml/badge.svg" alt="CI/CD status" /></a>
16
+ <!-- <a href="https://github.com/atolis-hq/wake/network/members"><img src="https://img.shields.io/github/forks/atolis-hq/wake" alt="forks" /></a>
17
+ <a href="https://github.com/atolis-hq/wake/stargazers"><img src="https://img.shields.io/github/stars/atolis-hq/wake" alt="stars" /></a> -->
18
+ <a href="https://github.com/atolis-hq/wake/issues/"><img src="https://img.shields.io/github/issues/atolis-hq/wake" alt="open issues" /></a>
19
+ <a href="https://github.com/atolis-hq/wake/blob/main/LICENSE"><img src="https://img.shields.io/github/license/atolis-hq/wake.svg" alt="license" /></a>
20
+ <a href="https://github.com/atolis-hq/wake/tags"><img src="https://img.shields.io/github/v/tag/atolis-hq/wake" alt="latest tag" /></a>
21
+ <a href="https://www.npmjs.com/package/@atolis-hq/wake"><img src="https://img.shields.io/npm/v/%40atolis-hq%2Fwake" alt="npm version" /></a>
22
+ <a href="docker/Dockerfile"><img src="https://img.shields.io/badge/sandbox-docker-2496ED?logo=docker&logoColor=white" alt="runs in docker" /></a>
41
23
  </p>
42
24
  </div>
43
25
 
@@ -63,7 +45,6 @@ conversation forward wherever the work is already happening.
63
45
  - [Where the Work Happens](#where-the-work-happens)
64
46
  - [Supported Agent CLIs](#supported-agent-clis)
65
47
  - [Getting Started](#getting-started)
66
- - [Development](#development)
67
48
  - [Documentation](#documentation)
68
49
  - [Issues & Feature Requests](#issues--feature-requests)
69
50
  - [License](#license)
@@ -115,8 +96,10 @@ For more detail, see [docs/vision.md](docs/vision.md) and
115
96
  - **Event-sourced and restart-safe.** The durable record is an append-only event
116
97
  log; projections can be rebuilt, and the loop can crash and resume without
117
98
  losing its place.
118
- - **Local and inspectable.** Config, events, state, runs, workspaces, and prompts
119
- live in a plain-file Wake home directory.
99
+ - **Local and inspectable.** Everything lives in a plain-file Wake home
100
+ directory: `config.json`, `prompts/`, and `workspaces/` at the top level for
101
+ what you edit or browse day-to-day, with durable/internal state (events,
102
+ projections, runs, logs, sandbox auth) nested under a hidden `.wake/`.
120
103
  - **Sandbox-oriented execution.** Wake can run from a persistent Docker sandbox
121
104
  with durable auth state and mounted Wake home data.
122
105
  - **Runner agnostic.** Claude Code, Codex, Cursor, and fake runners sit behind
@@ -162,82 +145,32 @@ for capability differences between runners.
162
145
 
163
146
  ## Getting Started
164
147
 
165
- Wake is distributed as the `@atolis-hq/wake` npm package. You can run the CLI
166
- with `npx` or install it globally:
167
-
168
- ```sh
169
- npx @atolis-hq/wake init ./wake-home
170
- ```
171
-
172
148
  ```sh
173
149
  npm install -g @atolis-hq/wake
150
+ cd ~/
174
151
  wake init ./wake-home
175
- ```
176
-
177
- `wake init` creates a Wake home directory with `config.json`, prompt templates,
178
- Docker sandbox assets, runtime directories, and shell launchers:
179
-
180
- - `wake.sh` for bash, Git Bash, WSL, and similar shells.
181
- - `wake.ps1` for PowerShell.
182
-
183
- Use the generated launcher from the Wake home for day-to-day operation. The
184
- launcher runs host setup commands locally and forwards runtime commands into the
185
- sandbox with the correct Wake home mounted at `/wake`.
186
-
187
- ```sh
188
152
  cd ./wake-home
189
- ./wake.sh sandbox build
190
- ./wake.sh sandbox up
191
- ./wake.sh sandbox setup
192
- ./wake.sh tick
193
- ./wake.sh start
153
+ wake sandbox build
154
+ wake sandbox up
155
+ wake sandbox setup
156
+ wake start
194
157
  ```
195
158
 
196
- The default sandbox image includes Node, Git, GitHub CLI, Claude Code, Codex,
197
- Cursor, and the Wake runtime. Treat the generated `docker/Dockerfile` as a
198
- starting point for your own environment: add the tools your repositories need,
199
- then rebuild with `./wake.sh sandbox build`. Wake writes the package location to
200
- `config.json` as `dev.repoRoot` so sandbox rebuilds use the same bundled assets;
201
- editing your generated Dockerfile or prompts is expected and future package
202
- upgrades should not overwrite that Wake home.
203
-
204
- Common commands:
205
-
206
- ```sh
207
- ./wake.sh ui
208
- ./wake.sh tick
209
- ./wake.sh start
210
- ./wake.sh stop
211
- ./wake.sh sandbox resume <session-id> --cwd "/wake/workspaces/<workId>"
212
- ```
213
-
214
- For a source checkout development workflow, use:
215
-
216
- - [docs/development.md](docs/development.md)
217
-
218
- Recommended local practices:
219
-
220
- - Use a separate git identity for Wake-managed agent work so automated commits
221
- and human commits are easy to distinguish.
222
- - Prefer the prebuilt sandbox flow when running real agent work locally.
223
- - Treat the default Dockerfile as a starting point. It includes common tooling
224
- such as Node, but it is expected to be edited for the repositories and agents
225
- you want Wake to operate on.
226
-
227
- ## Development
228
-
229
- Local setup, commands, sandbox operation, auth setup, UI notes, and GitHub
230
- polling details are documented in [docs/development.md](docs/development.md).
159
+ Full setup instructions are in
160
+ [docs/getting-started.md](docs/getting-started.md). Run `wake --help` at
161
+ any time for the full command list, or see
162
+ [docs/cli.md](docs/cli.md) for the full command reference.
231
163
 
232
164
  ## Documentation
233
165
 
166
+ - [docs/getting-started.md](docs/getting-started.md) — packaged-install setup, sandbox lifecycle, `wake doctor`.
167
+ - [docs/cli.md](docs/cli.md) — full CLI command reference.
234
168
  - [docs/vision.md](docs/vision.md) — the rationale and long-term direction for Wake.
235
169
  - [docs/architecture.md](docs/architecture.md) — module boundaries and the event-sourced core.
236
- - [docs/implementation.md](docs/implementation.md) — the accepted implementation plan.
237
170
  - [docs/workflows.md](docs/workflows.md) — how stages, prompts, and runner routes are configured.
238
171
  - [docs/prompts.md](docs/prompts.md) — how prompt templates map to workflow stages.
239
172
  - [docs/configuration.md](docs/configuration.md) — `config.json` options and the operator correlation escape hatch.
240
- - [docs/development.md](docs/development.md) — local setup and sandbox development workflow.
173
+ - [docs/development.md](docs/development.md) — source-checkout dev setup (`wake-dev`), npm scripts, formatting, self-update, GitHub polling.
241
174
  - [docs/runner-comparison.md](docs/runner-comparison.md) — capability differences between supported runners.
242
175
 
243
176
  ## Issues & Feature Requests
@@ -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);
@@ -17,6 +32,28 @@ async function ensureContainerHomeMountParents(input) {
17
32
  });
18
33
  }
19
34
  }
35
+ const sandboxSubcommands = [
36
+ ['build', 'Generate docker/Dockerfile (if missing) and build the sandbox image'],
37
+ ['up', 'Start the sandbox container'],
38
+ ['update', 'Recreate the sandbox container from the current image'],
39
+ ['down', 'Stop and remove the sandbox container'],
40
+ ['stop', 'Stop the resident loop gracefully, then the container'],
41
+ ['self-update', 'Pull the latest tag and rebuild (source dev.mode only)'],
42
+ ['setup', 'Run interactive first-time setup inside the container'],
43
+ ['exec', 'Run a command inside the sandbox container'],
44
+ ['logs', 'Print sandbox container logs'],
45
+ ['resume', 'Resume a previous agent session inside the sandbox'],
46
+ ];
47
+ export function printSandboxUsage(stream) {
48
+ const width = Math.max(...sandboxSubcommands.map(([name]) => name.length));
49
+ stream.write([
50
+ 'Usage: wake sandbox <subcommand>',
51
+ '',
52
+ 'Subcommands:',
53
+ ...sandboxSubcommands.map(([name, description]) => ` ${name.padEnd(width)} ${description}`),
54
+ '',
55
+ ].join('\n'));
56
+ }
20
57
  function readFlag(name, args) {
21
58
  const index = args.indexOf(name);
22
59
  if (index === -1) {
@@ -39,18 +76,29 @@ function startDockerInput(config) {
39
76
  }
40
77
  export async function runSandboxCommand(input) {
41
78
  const subcommand = input.args[0];
42
- if (subcommand === undefined) {
43
- throw new Error('Unknown sandbox command:');
79
+ if (subcommand === undefined ||
80
+ subcommand === '--help' ||
81
+ subcommand === '-h' ||
82
+ subcommand === 'help') {
83
+ printSandboxUsage(process.stdout);
84
+ return;
44
85
  }
45
86
  if (subcommand === 'build') {
46
87
  const repoRoot = input.config.dev?.repoRoot;
47
88
  if (repoRoot === undefined || repoRoot.length === 0) {
48
89
  throw new Error('Sandbox build requires config.dev.repoRoot');
49
90
  }
91
+ const effectiveDevMode = input.config.dev?.mode ?? 'packaged';
92
+ await ensureDockerfile({
93
+ wakeRoot: input.wakeRoot,
94
+ devMode: effectiveDevMode,
95
+ packagedTemplatesRoot: input.packagedTemplatesRoot,
96
+ });
50
97
  await input.docker.build({
51
98
  image: input.config.sandbox.image,
52
99
  dockerfile: resolve(input.wakeRoot, 'docker', 'Dockerfile'),
53
100
  contextDir: repoRoot,
101
+ ...(effectiveDevMode === 'packaged' ? { buildArgs: { WAKE_VERSION: wakeVersion } } : {}),
54
102
  });
55
103
  return;
56
104
  }
@@ -113,7 +161,8 @@ export async function runSandboxCommand(input) {
113
161
  throw new Error('Sandbox self-update requires config.dev.repoRoot');
114
162
  }
115
163
  if (input.selfUpdate === undefined) {
116
- throw new Error('Sandbox self-update requires git/issueReporter/ledger dependencies');
164
+ throw new Error('Sandbox self-update requires dev.mode: "source". For a packaged install, update instead with:\n' +
165
+ ' npm install -g @atolis-hq/wake@latest && wake sandbox build && wake sandbox update');
117
166
  }
118
167
  const selfUpdateArgs = input.args.slice(1);
119
168
  const runSelfUpdate = selfUpdateArgs.includes('--loop')
@@ -146,7 +195,10 @@ export async function runSandboxCommand(input) {
146
195
  return;
147
196
  }
148
197
  if (subcommand === 'setup') {
149
- await input.docker.exec(input.config.sandbox.containerName, ['bash', '/wake/docker/setup.sh'], {
198
+ const setupCommand = input.config.dev?.mode === 'source'
199
+ ? ['node', '/app/dist/src/main.js', 'sandbox-setup']
200
+ : ['wake', 'sandbox-setup'];
201
+ await input.docker.exec(input.config.sandbox.containerName, setupCommand, {
150
202
  interactive: true,
151
203
  });
152
204
  return;
@@ -154,15 +206,18 @@ export async function runSandboxCommand(input) {
154
206
  if (subcommand === 'exec') {
155
207
  const commandArgs = input.args.slice(1);
156
208
  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
- }));
209
+ if (wrappedCommand.length === 0) {
210
+ // No command given: drop into an interactive shell with a real TTY,
211
+ // same as `docker exec -it ... bash`. That's a genuine interactive
212
+ // use case, so it stays on the inherited-stdio path rather than the
213
+ // piped/scrubbed one below (which would break TTY behavior).
214
+ await input.docker.exec(input.config.sandbox.containerName, []);
215
+ return;
216
+ }
217
+ await input.docker.execCaptured(input.config.sandbox.containerName, wrappedCommand, {
218
+ onStdout: (line) => input.logger.info(line),
219
+ onStderr: (line) => (input.logger.error ?? input.logger.info)(line),
220
+ });
166
221
  return;
167
222
  }
168
223
  if (subcommand === 'logs') {
@@ -186,6 +241,7 @@ export async function runSandboxCommand(input) {
186
241
  wakeRoot: input.wakeRoot,
187
242
  containerHomeRoot: input.containerHomeRoot,
188
243
  buildResumeCommand: runnerAdapter.buildResumeCommand,
244
+ logger: input.logger,
189
245
  });
190
246
  return;
191
247
  }
@@ -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
  }