サンドボックスからWordPressを自動更新する構成

サンドボックスからWordPressを自動更新する構成

サンドボックスの中で動くエージェントに、自分のサイトを更新させようとして、私はしばらく手が止まりました。

投稿はすんなり通るのに、固定ページの更新だけが、何度やっても失敗する。

原因は障害でも権限でもなく、通信の「出口」が違うだけ、という拍子抜けするものでした。

同じようにローカルのサービスや API へ届かず、理由も分からないまま設定ばかり疑い続けた経験は、ないでしょうか。

前提(最小限)

Cowork(サンドボックス型のエージェント)を使い始めて、私が最初に飲み込んだのは実行環境の形でした。

このエージェントは隔離されたサンドボックスの中で動き、外へ出る通信は許可リスト方式で絞られています。

一方で、私のサイトを更新する認証付きの処理は、手元で常駐する自作の local_web(Next.js のアプリ)側にまとめてあるという構図。

前提として置くのは、この二点だけです。

サンドボックスの外向き通信は許可リスト制、そしてWordPress の認証はホスト側の local_web に閉じている、この二つを土台に話を進めます。

素朴にやると落ちる

到達性の切り分け図:許可リスト内のドメインには届くがリスト外はHTTP 000になること、投稿と固定ページで通信の出口が違ったことを整理

最初に私がやったのは、いちばん素直な方法でした。

サンドボックスの中から、サイトの REST API を直接叩いて更新する。

ところが、この素直な一手は何度試しても届かず、応答すら返ってきません。

到達性を一枚の表にする

原因を推測で潰す前に、私はまず「どこへは届いて、どこへは届かないのか」を一枚の表にして確かめました。

やり方は単純で、いくつかのドメインへ順に接続し、返ってきた HTTP ステータスを並べるだけです。

結果は、はっきりしていました。

pypi.org や github.com、API 系のホストは 200 や 404 が返り「到達」、ところが自分のサイトと一部のクラウドストレージだけが000、つまり接続そのものが成立しない

サンドボックスの外向きは許可リスト付きのプロキシを通っており、リストにないドメインはそもそも出ていけない、という単純な事情でした。

投稿は通るのに、固定ページだけ落ちる

ここで不思議だったのが、同じサイト相手でもブログ投稿の更新は問題なく通っていた点です。

「同じ宛先なのに、なぜ片方だけ落ちるのか」。

答えは拍子抜けするほど単純で、通信の出口が違っただけでした。

投稿を通していた処理はホスト常駐の local_web 側にあり、手元マシンの実回線から外へ出ていきます。

対して固定ページを直接叩く一手は、サンドボックスの中から出ようとして、許可リストの壁に阻まれる。

失敗の正体は障害でも権限でもなく、コードがどの出口から外に出るかの違いだった、と腑に落ちました。

解決構成

サンドボックスからWordPressを更新する構成図:直接RESTを叩くと許可リストで接続不能になるため、同一オリジンのタブを踏み台にホスト常駐のローカルAPI経由で更新する

壁の正体さえ見えてしまえば、直し方は自然と決まります。

やることは一つで、サイトを更新する副作用を、サンドボックスの外へ追い出すことです。

副作用はホスト側のローカルAPIへ寄せる

具体的には、WordPress を触るロジックを local_web 側のローカルAPIに寄せ、認証情報はサーバ側の環境変数だけが読む形にしました。

こうしておけば、鍵がブラウザやエージェントの側へ漏れる心配はありません。

秘密の鍵は、サーバの内側から一歩も外へ出さずに済むという設計です。

例えるなら、金庫の鍵を持ち歩かず、金庫の前の担当者に「開けて」と頼む形に近いでしょう。

エージェントは「更新して」と依頼するだけで、実際に鍵を回すのはホスト側、という役割分担にしています。

slug で重複なく更新する

もう一つ大事だったのが、同じ処理を二度走らせても事故らない作りにすることでした。

固定ページはまず slug で検索し、あれば更新、なければ新規作成というべき等な upsertにしています。

例えば、以下のような形です。

async upsertPageBySlug(slug, title, content, status = "publish") {
  const existing = await this.getPageBySlug(slug); // slug で存在確認
  const path = existing ? `/pages/${existing.id}` : "/pages";
  const res = await this.request("POST", path, {
    body: JSON.stringify({ title, slug, content, status }),
    // 省略(ヘッダ等)
  });
  return { id: res.id, action: existing ? "updated" : "created" };
}

実際に動かすと初回は作成、二度目以降は同じ ID への更新へ切り替わり、何度叩いても重複ページが増えない状態を確認できました。

呼び出しは同一オリジンのタブから

最後に残る問いは、そのローカルAPIを誰が叩くか、でした。

サンドボックスの中から手元マシンの localhost を呼んでも、両者は別物なので届きません。

そこで踏み台にしたのが、ブラウザでした。

Claude in Chrome で local_web を開いた同一オリジンのタブから、タブの中で fetch を実行して API を叩く

// ローカルの local_web を開いたタブ内で実行する
await fetch('/api/portfolio/publish', { method: 'POST' })
  .then(r => r.json());
// => { id: 1426, action: 'created' | 'updated' }

ブラウザはホストの実回線で通信するので、サンドボックスの壁をきれいに迂回できます。

定着のさせ方

一度うまくいった手順も、仕組みへ落とし込まなければ、すぐに忘れてしまうもの。

そこで私は、この一連の流れを週次のスケジュールタスクとして定着させました。

事前承認とポートの現実

ブラウザ操作は初回に一度、手動で「Run now」を通して事前承認を済ませておきます。

こうしておくと、以降は自動実行でもタブ操作が途中で止まりません。

ひとつ注意したいのが、localhost のポート番号は環境依存で、開発の既定と実運用で食い違う点です。

私の環境でも既定と運用で番号が違ったため、そこだけは環境に合わせて指定しています。

届かないときのフォールバック

自動化で怖いのは、静かに失敗して誰も気づかないことでした。

ホストのローカルWebが起動していない、あるいはブラウザが未接続という状況は、必ず起こります。

そこで、更新に失敗したときは成果物の HTML をファイルとして残し、手で貼り付けられる導線に切り替えるようにしました。

「動かないなら、せめて手作業に戻れるように」という保険です。

自動化は、失敗したときの逃げ道までを含めて設計してこそ、安心して任せられる。

最後に

今回いちばん学びになったのは、失敗の多くが「機能の問題」ではなく「通信の出口の問題」だったことでした。

サンドボックスから外部サイトを直接叩かない、この一点さえ守れば、道は自然と見えてきます。

副作用はホスト常駐のローカルAPIへ寄せ、鍵はサーバの内側へ閉じ込める。

呼び出しは同一オリジンのタブを踏み台にして、slug や ID で検索してから更新する重複のない形にする。

この考え方は WordPress に限らず、サンドボックスからホスト側のローカルサービスを扱うとき全般に効くはずです。

手元の環境が変わっても、「直接叩かず、出口を借りる」という発想は、そのまま残ります。

自動化の壁にぶつかったら、まず通信の出口を疑ってみたいですね。

以上です。